diff --git a/Cargo.toml b/Cargo.toml index 41440d9..b05fbe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,6 @@ resolver = "2" protobuf = "3.5" # WS crates -spotifyio = { path = "crates/spotifyio", version = "0.0.2", registry = "thetadev" } +spotifyio = { path = "crates/spotifyio", version = "0.0.1", registry = "thetadev" } spotifyio-protocol = { path = "crates/protocol", version = "0.1.0", registry = "thetadev" } -spotifyio-model = { path = "crates/model", version = "0.2.0", registry = "thetadev" } +spotifyio-model = { path = "crates/model", version = "0.1.0", registry = "thetadev" } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ff5a484..6a3bdef 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -20,7 +20,5 @@ tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } futures-util = "0.3" widevine = "0.1" console = "0.15" -serde_json = "1" -protobuf-json-mapping = "3" spotifyio = {workspace = true, features = ["oauth"]} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 271a128..4aad0e3 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -5,10 +5,8 @@ use clap::{Parser, Subcommand}; use futures_util::{stream, StreamExt, TryStreamExt}; use path_macro::path; use spotifyio::{ - model::{FileId, Id, IdConstruct, IdError, PlayableId, TrackId, UserId}, - pb::AudioFileFormat, - ApplicationCache, AuthCredentials, AuthenticationType, CdnUrl, PoolConfig, Session, - SessionConfig, SpotifyIoPool, + pb::AudioFileFormat, ApplicationCache, AuthCredentials, AuthenticationType, CdnUrl, FileId, + PoolConfig, Session, SessionConfig, SpotifyId, SpotifyIoPool, }; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; @@ -19,11 +17,6 @@ use widevine::{Cdm, Device, Pssh}; struct Cli { #[clap(subcommand)] command: Commands, - #[clap(long, default_value = "en")] - lang: String, - /// Path to Spotify account cache - #[clap(long)] - cache: Option, } #[derive(Subcommand)] @@ -39,10 +32,6 @@ enum Commands { Accounts, Track { id: String, - #[clap(long)] - key: bool, - #[clap(long)] - web: bool, }, Album { id: String, @@ -50,12 +39,6 @@ enum Commands { Artist { id: String, }, - Playlist { - id: String, - }, - User { - id: String, - }, Prerelease { id: String, }, @@ -66,21 +49,11 @@ enum Commands { id: String, }, Upkeep, - AudioFeatures { - id: String, - }, - AudioAnalysis { - id: String, - }, - Convertid { - b64: String, - }, } #[tokio::main] async fn main() -> Result<(), Error> { tracing_subscriber::fmt::SubscriberBuilder::default() - .with_writer(std::io::stderr) .with_env_filter( EnvFilter::builder() .with_default_directive(LevelFilter::INFO.into()) @@ -88,22 +61,16 @@ async fn main() -> Result<(), Error> { .unwrap(), ) .init(); - let cli = Cli::parse(); - let path = cli.cache.unwrap_or(path!( - env!("CARGO_MANIFEST_DIR") / ".." / ".." / "data" / "spotifyio.json" - )); + let path = path!(env!("CARGO_MANIFEST_DIR") / ".." / ".." / "data" / "spotifyio.json"); let app_cache = ApplicationCache::new(path); - let scfg = SessionConfig { - language: cli.lang, - ..Default::default() - }; + let cli = Cli::parse(); match cli.command { Commands::Login => { let token = spotifyio::oauth::get_access_token(None).await?; let cache = spotifyio::oauth::new_session(&app_cache, &token.access_token).await?; - let session = Session::new(scfg, cache); + let session = Session::new(SessionConfig::default(), cache); session .connect(AuthCredentials { user_id: None, @@ -133,139 +100,99 @@ async fn main() -> Result<(), Error> { ); } } - Commands::Track { id, key, web } => { + Commands::Track { id } => { let pool = SpotifyIoPool::from_app_cache( app_cache, - &scfg, + &SessionConfig::default(), &PoolConfig { max_sessions: 1, ..Default::default() }, )?; - let spotify_id = parse_idstr(&id).unwrap(); + let spotify_id = SpotifyId::from_base62(&id).unwrap(); - if web { - let track = pool.spclient()?.web_track(spotify_id, None).await?; - println!("{}", serde_json::to_string(&track).unwrap()); - } else { - let track_et = pool.spclient()?.pb_track(spotify_id, None).await?; - tracing::info!("ETag: {}", track_et.etag.unwrap_or_default()); - let track = track_et.data; - println!( - "{}", - protobuf_json_mapping::print_to_string(&track).unwrap() - ); + let track = pool.spclient()?.pb_track(&spotify_id).await?; + dbg!(&track); - if key { - let spotify_id = TrackId::from_raw(track.gid()).unwrap(); - println!("Track id: {}", spotify_id.id()); + let spotify_id = SpotifyId::from_raw(track.gid()).unwrap(); + println!("Track id: {}", spotify_id.to_base62()); - for file in &track.file { - println!("{} {:?}", FileId::from_raw(file.file_id())?, file.format()); - } - - let file = track - .file - .iter() - .find(|file| matches!(file.format(), AudioFileFormat::OGG_VORBIS_160)) - .expect("audio file"); - let file_id = FileId::from_raw(file.file_id())?; - - let key = pool - .audio_key() - .await? - .request(PlayableId::Track(spotify_id), &file_id) - .await?; - println!("Audio key: {}", data_encoding::HEXLOWER.encode(&key.0)); - } + for file in &track.file { + println!("{} {:?}", FileId::from_raw(file.file_id())?, file.format()); } + + let file = track + .file + .iter() + .find(|file| matches!(file.format(), AudioFileFormat::OGG_VORBIS_160)) + .expect("audio file"); + let file_id = FileId::from_raw(file.file_id())?; + + let key = pool + .audio_key() + .await? + .request(&spotify_id, &file_id) + .await?; + println!("Audio key: {}", data_encoding::HEXLOWER.encode(&key.0)); } Commands::Album { id } => { let pool = SpotifyIoPool::from_app_cache( app_cache, - &scfg, + &SessionConfig::default(), &PoolConfig { max_sessions: 1, ..Default::default() }, )?; - let id = parse_idstr(&id)?; - let album_et = pool.spclient()?.pb_album(id, None).await?; + let id = SpotifyId::from_base62(&id)?; + let album = pool.spclient()?.pb_album(&id).await?; - tracing::info!("ETag: {}", album_et.etag.unwrap_or_default()); - println!( - "{}", - protobuf_json_mapping::print_to_string(&album_et.data).unwrap() - ); + dbg!(&album); + + for disc in album.disc { + for track in disc.track { + println!("{}", SpotifyId::from_raw(track.gid())?.to_base62()) + } + } } Commands::Artist { id } => { let pool = SpotifyIoPool::from_app_cache( app_cache, - &scfg, + &SessionConfig::default(), &PoolConfig { max_sessions: 1, ..Default::default() }, )?; - let id = parse_idstr(&id)?; - let artist_et = pool.spclient()?.pb_artist(id, None).await?; - tracing::info!("ETag: {}", artist_et.etag.unwrap_or_default()); - println!( - "{}", - protobuf_json_mapping::print_to_string(&artist_et.data).unwrap() - ); - } - Commands::Playlist { id } => { - let pool = SpotifyIoPool::from_app_cache( - app_cache, - &scfg, - &PoolConfig { - max_sessions: 1, - ..Default::default() - }, - )?; + let id = SpotifyId::from_base62(&id)?; + let artist = pool.spclient()?.pb_artist(&id).await?; - let id = parse_idstr(&id)?; - - let playlist = pool.spclient()?.pb_playlist(id, None).await?; - tracing::info!("ETag: {}", playlist.etag.unwrap_or_default()); - dbg!(&playlist.data); - } - Commands::User { id } => { - let pool = SpotifyIoPool::from_app_cache( - app_cache, - &scfg, - &PoolConfig { - max_sessions: 1, - ..Default::default() - }, - )?; - - let id = UserId::from_id(&id)?; - let user = pool.spclient()?.get_user_profile(id, 20).await?; - println!("{}", serde_json::to_string(&user).unwrap()); + dbg!(&artist); } Commands::Prerelease { id } => { let pool = SpotifyIoPool::from_app_cache( app_cache, - &scfg, + &SessionConfig::default(), &PoolConfig { max_sessions: 1, ..Default::default() }, )?; - let id = parse_idstr(&id)?; - let prerelease = pool.spclient()?.gql_prerelease(id).await?; - println!("{}", serde_json::to_string(&prerelease).unwrap()); + let id = SpotifyId::from_base62(&id)?; + let prerelease = pool.spclient()?.gql_prerelease(&id).await?; + dbg!(&prerelease); } Commands::Ratelimit { ids } => { - let pool = SpotifyIoPool::from_app_cache(app_cache, &scfg, &PoolConfig::default())?; - let txt = std::fs::read_to_string(ids)?; - let ids = txt + let pool = SpotifyIoPool::from_app_cache( + app_cache, + &SessionConfig::default(), + &PoolConfig::default(), + )?; + let ids = std::fs::read_to_string(ids)? .lines() - .filter_map(|line| parse_idstr(line).ok()) + .filter_map(|line| SpotifyId::from_base62(line).ok()) .collect::>(); let client = pool.spclient()?; @@ -275,9 +202,9 @@ async fn main() -> Result<(), Error> { // let pool = pool.clone(); let client = client.clone(); async move { - let artist = client.gql_artist_overview(id).await?; + let artist = client.gql_artist_overview(&id).await?; if let Some(p) = artist.pre_release { - let prerelease = client.gql_prerelease(p.uri).await?; + let prerelease = client.gql_prerelease(&p.uri).await?; tracing::info!( "Prerelease: [{}] {}", prerelease.uri, @@ -317,14 +244,14 @@ async fn main() -> Result<(), Error> { Commands::Widevine { id } => { let pool = SpotifyIoPool::from_app_cache( app_cache, - &scfg, + &SessionConfig::default(), &PoolConfig { max_sessions: 1, ..Default::default() }, )?; - let spid = parse_idstr(&id)?; - let track = pool.spclient()?.pb_track(spid, None).await?.data; + let spid = SpotifyId::from_base62(&id)?; + let track = pool.spclient()?.pb_track(&spid).await?; let mp4_file = track .file .iter() @@ -338,7 +265,7 @@ async fn main() -> Result<(), Error> { // let test_key = pool.audio_key().await?.request(&spid, &file_id).await?; let device = Device::read_wvd(BufReader::new(File::open("/home/thetadev/test/votify/output_path/samsung_sm-a137f_16.1.1@006_6653cefd_28919_l3.wvd").unwrap())).unwrap(); - let certificate = std::fs::read("/home/thetadev/Documents/Programmieren/Rust/widevine-rs/crates/widevine/testfiles/application-certificate").unwrap(); + let certificate = std::fs::read("/home/thetadev/Documents/Programmieren/Rust/spotifyio/crates/widevine/testfiles/application-certificate").unwrap(); let cdm = Cdm::new(device); let pssh = Pssh::from_b64(&seektable.pssh)?; @@ -353,13 +280,13 @@ async fn main() -> Result<(), Error> { let keys = lic.get_keys(&license_message)?; println!("[{}] {}", id, track.name()); - println!("{}", url.0); + println!("{url}"); dbg!(keys); } Commands::Upkeep => { let pool = SpotifyIoPool::from_app_cache( app_cache, - &scfg, + &SessionConfig::default(), &PoolConfig { max_sessions: 1, ..Default::default() @@ -368,16 +295,13 @@ async fn main() -> Result<(), Error> { pool.connect().await.unwrap(); let stdout = console::Term::buffered_stdout(); - let spotify_id = parse_idstr::("4AY7RuEn7nmnxtvya5ygCt").unwrap(); + let spotify_id = SpotifyId::from_base62("4AY7RuEn7nmnxtvya5ygCt").unwrap(); let file_id = FileId::from_base16("560ad6f0d543aa20e7851f891b4e40ba47d54b54").unwrap(); loop { _ = stdout.read_char(); match pool.audio_key_timeout(Duration::from_secs(10)).await { - Ok(km) => match km - .request(PlayableId::Track(spotify_id.as_ref()), &file_id) - .await - { + Ok(km) => match km.request(&spotify_id, &file_id).await { Ok(key) => { println!("Audio key: {}", data_encoding::HEXLOWER.encode(&key.0)) } @@ -387,49 +311,6 @@ async fn main() -> Result<(), Error> { } } } - Commands::AudioFeatures { id } => { - let pool = SpotifyIoPool::from_app_cache( - app_cache, - &scfg, - &PoolConfig { - max_sessions: 1, - ..Default::default() - }, - )?; - - let track_id = parse_idstr(&id)?; - let features = pool.spclient()?.web_track_features(track_id).await?; - println!("{}", serde_json::to_string_pretty(&features)?); - } - Commands::AudioAnalysis { id } => { - let pool = SpotifyIoPool::from_app_cache( - app_cache, - &scfg, - &PoolConfig { - max_sessions: 1, - ..Default::default() - }, - )?; - - let track_id = parse_idstr(&id)?; - let features = pool.spclient()?.web_track_analysis(track_id).await?; - println!("{}", serde_json::to_string_pretty(&features)?); - } - Commands::Convertid { b64 } => { - let id = parse_idstr::(&b64)?; - println!("{id}"); - } } Ok(()) } - -fn parse_idstr<'a, T: IdConstruct<'a>>(s: &'a str) -> Result { - if s.ends_with("==") { - let bytes = data_encoding::BASE64 - .decode(s.as_bytes()) - .map_err(|_| IdError::InvalidId)?; - T::from_raw(&bytes) - } else { - T::from_id_or_uri(s) - } -} diff --git a/crates/downloader/.sqlx/query-07c4c0a0ba528964a13c8c9f8771dc37a92e9f9fbba314290e59c0cc7428158c.json b/crates/downloader/.sqlx/query-07c4c0a0ba528964a13c8c9f8771dc37a92e9f9fbba314290e59c0cc7428158c.json deleted file mode 100644 index acde44b..0000000 --- a/crates/downloader/.sqlx/query-07c4c0a0ba528964a13c8c9f8771dc37a92e9f9fbba314290e59c0cc7428158c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "update artists set last_update=$2, etag=$3 where id=$1", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "07c4c0a0ba528964a13c8c9f8771dc37a92e9f9fbba314290e59c0cc7428158c" -} diff --git a/crates/downloader/.sqlx/query-17be8832946ce5585d93cb4e76d597624a633817d76ac34dd6378e7210b644e5.json b/crates/downloader/.sqlx/query-17be8832946ce5585d93cb4e76d597624a633817d76ac34dd6378e7210b644e5.json deleted file mode 100644 index d2baac1..0000000 --- a/crates/downloader/.sqlx/query-17be8832946ce5585d93cb4e76d597624a633817d76ac34dd6378e7210b644e5.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "insert into albums (id, name) values ($1, $2)", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "17be8832946ce5585d93cb4e76d597624a633817d76ac34dd6378e7210b644e5" -} diff --git a/crates/downloader/.sqlx/query-419ae5a6ca51a68acf24c8fc46f846ce0be655af44a1fb7bf0501f31bc5ad715.json b/crates/downloader/.sqlx/query-419ae5a6ca51a68acf24c8fc46f846ce0be655af44a1fb7bf0501f31bc5ad715.json deleted file mode 100644 index a628758..0000000 --- a/crates/downloader/.sqlx/query-419ae5a6ca51a68acf24c8fc46f846ce0be655af44a1fb7bf0501f31bc5ad715.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "select count(*) as count from artists where active=1 and (last_update is null or last_update < $1)", - "describe": { - "columns": [ - { - "name": "count", - "ordinal": 0, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "419ae5a6ca51a68acf24c8fc46f846ce0be655af44a1fb7bf0501f31bc5ad715" -} diff --git a/crates/downloader/.sqlx/query-4c6d9967a54a12626f864c6e388fcc93c1d98800fb4b771724fec8b38b6cbbae.json b/crates/downloader/.sqlx/query-4c6d9967a54a12626f864c6e388fcc93c1d98800fb4b771724fec8b38b6cbbae.json deleted file mode 100644 index f7f8064..0000000 --- a/crates/downloader/.sqlx/query-4c6d9967a54a12626f864c6e388fcc93c1d98800fb4b771724fec8b38b6cbbae.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "select id from albums where id=$1", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true - ] - }, - "hash": "4c6d9967a54a12626f864c6e388fcc93c1d98800fb4b771724fec8b38b6cbbae" -} diff --git a/crates/downloader/.sqlx/query-77f3d314857a7d101506c00d4416565f194f575c8de0c6f57f7eb8ff21b65b8f.json b/crates/downloader/.sqlx/query-77f3d314857a7d101506c00d4416565f194f575c8de0c6f57f7eb8ff21b65b8f.json deleted file mode 100644 index 149723e..0000000 --- a/crates/downloader/.sqlx/query-77f3d314857a7d101506c00d4416565f194f575c8de0c6f57f7eb8ff21b65b8f.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "SQLite", - "query": "select id, name, etag from artists where active=1 and (last_update is null or last_update < $1)", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "etag", - "ordinal": 2, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true, - true, - true - ] - }, - "hash": "77f3d314857a7d101506c00d4416565f194f575c8de0c6f57f7eb8ff21b65b8f" -} diff --git a/crates/downloader/.sqlx/query-84a7b6606d0e2f04c40c7bae39cff81e77b5871b5bf74bc9c6faf7658a946980.json b/crates/downloader/.sqlx/query-84a7b6606d0e2f04c40c7bae39cff81e77b5871b5bf74bc9c6faf7658a946980.json deleted file mode 100644 index edb79d7..0000000 --- a/crates/downloader/.sqlx/query-84a7b6606d0e2f04c40c7bae39cff81e77b5871b5bf74bc9c6faf7658a946980.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "update tracks set not_available=true where id=?", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "84a7b6606d0e2f04c40c7bae39cff81e77b5871b5bf74bc9c6faf7658a946980" -} diff --git a/crates/downloader/.sqlx/query-8aa211d6b230db73f13394bb7a671af96993137b373c6647134853e93c57c923.json b/crates/downloader/.sqlx/query-8aa211d6b230db73f13394bb7a671af96993137b373c6647134853e93c57c923.json deleted file mode 100644 index 80dd1c3..0000000 --- a/crates/downloader/.sqlx/query-8aa211d6b230db73f13394bb7a671af96993137b373c6647134853e93c57c923.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "select genre from tracks where album_artist_id=? order by genre nulls last limit 1", - "describe": { - "columns": [ - { - "name": "genre", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true - ] - }, - "hash": "8aa211d6b230db73f13394bb7a671af96993137b373c6647134853e93c57c923" -} diff --git a/crates/downloader/.sqlx/query-a26fa646feb96469fcfaae8cd118eacddd65f0746c68243a27af1ec128dad3d1.json b/crates/downloader/.sqlx/query-a26fa646feb96469fcfaae8cd118eacddd65f0746c68243a27af1ec128dad3d1.json deleted file mode 100644 index c570c69..0000000 --- a/crates/downloader/.sqlx/query-a26fa646feb96469fcfaae8cd118eacddd65f0746c68243a27af1ec128dad3d1.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "insert into artists (id, name, active) values ($1, $2, true)", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "a26fa646feb96469fcfaae8cd118eacddd65f0746c68243a27af1ec128dad3d1" -} diff --git a/crates/downloader/.sqlx/query-a59e3b332fa9fb25c39f2d788fa837738be313b9cbc7e297ac15530efbaa7e3d.json b/crates/downloader/.sqlx/query-a59e3b332fa9fb25c39f2d788fa837738be313b9cbc7e297ac15530efbaa7e3d.json deleted file mode 100644 index 27dcddd..0000000 --- a/crates/downloader/.sqlx/query-a59e3b332fa9fb25c39f2d788fa837738be313b9cbc7e297ac15530efbaa7e3d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "insert into tracks (id, spotify_id, name, artist, artists, album, cover_url, audio_url, format, key,\n track_nr, disc_nr, downloaded, album_id, album_artist, album_artist_id, release_date, n_discs,\n album_type, genre, not_available, size, path)\n values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, true, $13, $14, $15, $16, $17, $18, $19, false, $20, $21)", - "describe": { - "columns": [], - "parameters": { - "Right": 21 - }, - "nullable": [] - }, - "hash": "a59e3b332fa9fb25c39f2d788fa837738be313b9cbc7e297ac15530efbaa7e3d" -} diff --git a/crates/downloader/.sqlx/query-a9e2d1d9656ad3f059dc1c17ab60703b5650aeb72f4253c9eddea533707aff73.json b/crates/downloader/.sqlx/query-a9e2d1d9656ad3f059dc1c17ab60703b5650aeb72f4253c9eddea533707aff73.json deleted file mode 100644 index 56420e3..0000000 --- a/crates/downloader/.sqlx/query-a9e2d1d9656ad3f059dc1c17ab60703b5650aeb72f4253c9eddea533707aff73.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "select downloaded from tracks where id=$1", - "describe": { - "columns": [ - { - "name": "downloaded", - "ordinal": 0, - "type_info": "Bool" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true - ] - }, - "hash": "a9e2d1d9656ad3f059dc1c17ab60703b5650aeb72f4253c9eddea533707aff73" -} diff --git a/crates/downloader/.sqlx/query-d75195ed340e3961933bbe69946d03ecfa24f0237c40666088bacc7893a101ff.json b/crates/downloader/.sqlx/query-d75195ed340e3961933bbe69946d03ecfa24f0237c40666088bacc7893a101ff.json deleted file mode 100644 index 78c4481..0000000 --- a/crates/downloader/.sqlx/query-d75195ed340e3961933bbe69946d03ecfa24f0237c40666088bacc7893a101ff.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "update tracks set spotify_id=$2, name=$3, artist=$4, artists=$5,\n album=$6, cover_url=$7, audio_url=$8, format=$9, key=$10,\n track_nr=$11, disc_nr=$12, downloaded=true, album_id=$13,\n album_artist=$14, album_artist_id=$15, release_date=$16, n_discs=$17,\n album_type=$18, genre=$19, not_available=false, size=$20, path=$21\n where id=$1", - "describe": { - "columns": [], - "parameters": { - "Right": 21 - }, - "nullable": [] - }, - "hash": "d75195ed340e3961933bbe69946d03ecfa24f0237c40666088bacc7893a101ff" -} diff --git a/crates/downloader/.sqlx/query-de9c04eb8f8f961579e0b58244355be99eefc34938bab5ccdc0f01c0d1fa9e7d.json b/crates/downloader/.sqlx/query-de9c04eb8f8f961579e0b58244355be99eefc34938bab5ccdc0f01c0d1fa9e7d.json deleted file mode 100644 index 16b3192..0000000 --- a/crates/downloader/.sqlx/query-de9c04eb8f8f961579e0b58244355be99eefc34938bab5ccdc0f01c0d1fa9e7d.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "select n_discs from tracks where album_id=? and n_discs is not null limit 1", - "describe": { - "columns": [ - { - "name": "n_discs", - "ordinal": 0, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true - ] - }, - "hash": "de9c04eb8f8f961579e0b58244355be99eefc34938bab5ccdc0f01c0d1fa9e7d" -} diff --git a/crates/downloader/Cargo.toml b/crates/downloader/Cargo.toml index 438e6b0..3da2bfb 100644 --- a/crates/downloader/Cargo.toml +++ b/crates/downloader/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spotifyio-downloader" description = "CLI for downloading music from Spotify" -version = "0.3.1" +version = "0.1.0" edition.workspace = true authors.workspace = true license.workspace = true @@ -47,7 +47,7 @@ sqlx = { version = "0.8.0", features = [ aes = "0.8" ctr = "0.9" filenamify = "0.1.0" -lofty = "0.22.0" +lofty = "0.21.0" walkdir = "2.3.3" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -58,7 +58,7 @@ time = { version = "0.3.21", features = [ "parsing", ] } once_cell = "1.0" -itertools = "0.14.0" +itertools = "0.13.0" reqwest = { version = "0.12.0", features = [ "stream", ], default-features = false } diff --git a/crates/downloader/migrations/20241129160324_artist_etag.sql b/crates/downloader/migrations/20241129160324_artist_etag.sql deleted file mode 100644 index b73d2f8..0000000 --- a/crates/downloader/migrations/20241129160324_artist_etag.sql +++ /dev/null @@ -1 +0,0 @@ -alter table artists add column etag TEXT; diff --git a/crates/downloader/migrations/20241211183357_audio_features.sql b/crates/downloader/migrations/20241211183357_audio_features.sql deleted file mode 100644 index 363d7c9..0000000 --- a/crates/downloader/migrations/20241211183357_audio_features.sql +++ /dev/null @@ -1 +0,0 @@ -alter table tracks add column audio_features TEXT; diff --git a/crates/downloader/src/lib.rs b/crates/downloader/src/lib.rs index b1bc9ae..0a6488c 100644 --- a/crates/downloader/src/lib.rs +++ b/crates/downloader/src/lib.rs @@ -27,10 +27,9 @@ use once_cell::sync::Lazy; use path_macro::path; use serde::Serialize; use spotifyio::{ - model::{AlbumId, ArtistId, EpisodeId, FileId, Id, IdConstruct, IdError, PlayableId, TrackId}, - pb::AudioFileFormat, - AudioKey, CdnUrl, Error as SpotifyError, NormalisationData, NotModifiedRes, PoolConfig, - PoolError, Quota, Session, SessionConfig, SpotifyIoPool, + pb::AudioFileFormat, AudioKey, CdnUrl, Error as SpotifyError, FileId, IdError, + NormalisationData, PoolConfig, PoolError, Quota, Session, SessionConfig, SpotifyId, + SpotifyIoPool, SpotifyItemType, }; use spotifyio_protocol::metadata::{Album, Availability, Restriction, Track}; use sqlx::{sqlite::SqliteConnectOptions, ConnectOptions, SqlitePool}; @@ -100,7 +99,6 @@ pub struct SpotifyDownloaderInner { multi: Option, mp4decryptor: Option, cdm: Option, - apply_tags: bool, format_m4a: bool, widevine_cert: RwLock>, } @@ -272,7 +270,6 @@ pub struct SpotifyDownloaderConfig { pub progress: Option, pub widevine_device: Option, pub mp4decryptor: Option, - pub apply_tags: bool, pub format_m4a: bool, } @@ -287,7 +284,6 @@ impl Default for SpotifyDownloaderConfig { progress: None, widevine_device: None, mp4decryptor: Mp4Decryptor::from_env(), - apply_tags: true, format_m4a: false, } } @@ -324,7 +320,6 @@ impl SpotifyDownloader { multi: cfg.progress, cdm, mp4decryptor: cfg.mp4decryptor, - apply_tags: cfg.apply_tags, format_m4a: cfg.format_m4a, widevine_cert: RwLock::new(None), } @@ -388,17 +383,17 @@ impl SpotifyDownloader { Ok(cert) } - async fn get_audio_item(&self, spotify_id: PlayableId<'_>) -> Result { + async fn get_audio_item(&self, spotify_id: &SpotifyId) -> Result { let session = self.i.sp.session()?; - match spotify_id { - PlayableId::Track(id) => { - let track = session.spclient().pb_track(id, None).await?.data; + match spotify_id.item_type { + SpotifyItemType::Track => { + let track = session.spclient().pb_track(spotify_id).await?; map_track(session, track) } - PlayableId::Episode(id) => { - let episode = session.spclient().pb_episode(id, None).await?.data; + SpotifyItemType::Episode => { + let episode = session.spclient().pb_episode(spotify_id).await?; Ok(AudioItem { - track_id: PlayableId::Episode(EpisodeId::from_raw(episode.gid())?), + track_id: SpotifyId::from_raw(episode.gid())?.episode(), actual_id: None, files: AudioFiles::from(episode.audio.as_slice()), covers: get_covers(episode.cover_image.image.iter()), @@ -420,15 +415,16 @@ impl SpotifyDownloader { }), }) } + _ => Err(MetadataError::NonPlayable.into()), } } async fn load_track( &self, - spotify_id: PlayableId<'_>, + spotify_id: &SpotifyId, ) -> Result<(AudioItem, FileId, AudioFileFormat), Error> { let audio_item = self.get_audio_item(spotify_id).await?; - let audio_item = self.find_available_alternative(audio_item)?; + let audio_item = self.find_available_alternative(audio_item).await?; tracing::info!( "Loading <{}> with Spotify URI <{}>", @@ -455,23 +451,27 @@ impl SpotifyDownloader { formats_default.as_slice() }; - let (format, file_id) = formats - .iter() - .find_map(|format| match audio_item.files.0.get(format) { - Some(&file_id) => Some((*format, file_id)), - _ => None, - }) - .ok_or_else(|| Error::Unavailable("Format unavailable".to_owned()))?; + let (format, file_id) = + match formats + .iter() + .find_map(|format| match audio_item.files.0.get(format) { + Some(&file_id) => Some((*format, file_id)), + _ => None, + }) { + Some(t) => t, + None => { + return Err(Error::Unavailable("Format unavailable".to_owned())); + } + }; Ok((audio_item, file_id, format)) } async fn get_file_key_clearkey( &self, - spotify_id: PlayableId<'_>, + spotify_id: &SpotifyId, file_id: FileId, ) -> Result { - tracing::debug!("getting clearkey for {file_id}"); let key = self .i .sp @@ -485,7 +485,6 @@ impl SpotifyDownloader { } async fn get_file_key_widevine(&self, file_id: FileId) -> Result { - tracing::debug!("getting widevine key for {file_id}"); let cdm = self .i .cdm @@ -495,7 +494,6 @@ impl SpotifyDownloader { let seektable = sp.get_seektable(&file_id).await?; let cert = self.get_widevine_cert().await?; - tracing::debug!("getting widevine license for PSSH {}", seektable.pssh); let pssh = Pssh::from_b64(&seektable.pssh)?; let request = cdm .open() @@ -512,7 +510,7 @@ impl SpotifyDownloader { async fn get_file_key( &self, - spotify_id: PlayableId<'_>, + spotify_id: &SpotifyId, file_id: FileId, format: AudioFileFormat, ) -> Result { @@ -527,8 +525,8 @@ impl SpotifyDownloader { } } - async fn get_genre(&self, artist_id: ArtistId<'_>) -> Result, Error> { - let aid_str = artist_id.id().to_owned(); + async fn get_genre(&self, artist_id: &SpotifyId) -> Result, Error> { + let aid_str = artist_id.to_base62().into_owned(); { let gc = GENRE_CACHE.read().unwrap(); @@ -547,7 +545,7 @@ impl SpotifyDownloader { let got = if let Some(res) = res { res.genre.map(|s| capitalize(&s)) } else { - let artist = self.i.sp.spclient()?.pb_artist(artist_id, None).await?.data; + let artist = self.i.sp.spclient()?.pb_artist(artist_id).await?; artist.genre.first().map(|s| capitalize(s)) }; @@ -558,7 +556,7 @@ impl SpotifyDownloader { /// Spotify does not output the number of discs of an album in its track metadata. /// Therefore we need to fetch the entire album and get the highest disc number. /// We store the result in the database so we dont need to fetch this for every track. - async fn get_n_discs(&self, album_id: AlbumId<'_>) -> Result { + async fn get_n_discs(&self, album_id: &SpotifyId) -> Result { let aid_str = album_id.to_string(); { @@ -577,29 +575,32 @@ impl SpotifyDownloader { let final_res = if let Some(res) = res { res.n_discs.unwrap() as u32 } else { - let album = self.i.sp.spclient()?.pb_album(album_id, None).await?.data; + let album = self.i.sp.spclient()?.pb_album(album_id).await?; album_n_discs(&album) }; N_DISCS_CACHE.write().unwrap().insert(aid_str, final_res); Ok(final_res) } - async fn mark_track_unavailable(&self, track_id: TrackId<'_>) -> Result<(), Error> { - let id = track_id.id(); + async fn mark_track_unavailable(&self, track_id: &SpotifyId) -> Result<(), Error> { + let id = track_id.to_base62(); sqlx::query!("update tracks set not_available=true where id=?", id) .execute(&self.i.pool) .await?; Ok(()) } - fn find_available_alternative(&self, mut audio_item: AudioItem) -> Result { + async fn find_available_alternative( + &self, + mut audio_item: AudioItem, + ) -> Result { if audio_item.availability.is_ok() && !audio_item.files.0.is_empty() { return Ok(audio_item); } for alt in audio_item.alternatives { if alt.availability.is_ok() { - audio_item.actual_id = Some(PlayableId::Track(alt.track_id)); + audio_item.actual_id = Some(alt.track_id); audio_item.alternatives = Vec::new(); audio_item.availability = alt.availability; audio_item.files = alt.files; @@ -688,11 +689,10 @@ impl SpotifyDownloader { #[tracing::instrument(level = "error", skip(self, force))] pub async fn download_track( &self, - track_id: TrackId<'_>, + track_id: &SpotifyId, force: bool, ) -> Result, Error> { - let pid = PlayableId::Track(track_id.as_ref()); - let input_id_str = track_id.id().to_owned(); + let input_id_str = track_id.to_base62(); // Check if track was already downloaded let row = sqlx::query!("select downloaded from tracks where id=$1", input_id_str) .fetch_optional(&self.i.pool) @@ -704,12 +704,12 @@ impl SpotifyDownloader { // Get stream from Spotify let sess = self.i.sp.session()?; - let (sp_item, file_id, audio_format) = self.load_track(pid.as_ref()).await?; + let (sp_item, file_id, audio_format) = self.load_track(track_id).await?; let cdn_url = CdnUrl::new(file_id) .resolve_audio(self.i.sp.session()?) .await?; - let audio_url = &cdn_url.try_get_url()?.0; - let key = self.get_file_key(pid, file_id, audio_format).await?; + let audio_url = cdn_url.try_get_url()?; + let key = self.get_file_key(track_id, file_id, audio_format).await?; let track_fields = if let UniqueFields::Track(f) = &sp_item.unique_fields { f @@ -718,7 +718,7 @@ impl SpotifyDownloader { "podcast episodes are not supported", ))); }; - let n_discs = self.get_n_discs(track_fields.album_id.as_ref()).await?; + let n_discs = self.get_n_discs(&track_fields.album_id).await?; let title = &sp_item.name; let artist = track_fields @@ -735,8 +735,8 @@ impl SpotifyDownloader { .max_by_key(|img| img.width) .ok_or(MetadataError::Missing("album cover"))?; let artists_json = serde_json::to_string(&track_fields.artists).unwrap(); - let genre = self.get_genre(ArtistId::from_id(&artist.id)?).await?; - let album_id_str = track_fields.album_id.id(); + let genre = self.get_genre(&SpotifyId::from_base62(&artist.id)?).await?; + let album_id_str = track_fields.album_id.to_base62(); let artist_dir = path!(self.i.base_dir / better_filenamify(&album_artist.name, None)); if !artist_dir.is_dir() { @@ -744,9 +744,8 @@ impl SpotifyDownloader { let img_path = path!(artist_dir / "artist.jpg"); let artist = sess .spclient() - .pb_artist(ArtistId::from_id(&album_artist.id)?, None) - .await? - .data; + .pb_artist(&SpotifyId::from_base62(&album_artist.id)?) + .await?; let image = artist .portrait_group @@ -774,7 +773,7 @@ impl SpotifyDownloader { let album_folder = album_folder( &album_artist.name, &track_fields.album_name, - album_id_str, + &album_id_str, track_fields.disc_number, n_discs, ); @@ -787,6 +786,44 @@ impl SpotifyDownloader { std::fs::create_dir_all(&album_dir)?; // Download the file + + /* + let res = sess + .http_client() + .get(audio_url) + .send() + .await? + .error_for_status()?; + let file_size = i64::try_from( + res.content_length() + .ok_or_else(|| SpotifyError::failed_precondition("no file size"))? + - 167, + ) + .unwrap(); + + let mut file = tokio::fs::File::create(&tpath_tmp).await?; + let mut stream = res.bytes_stream(); + let mut first = true; + let mut cipher: Aes128Ctr = + Aes128Ctr::new_from_slices(&key.0, &spotifyio::AUDIO_AESIV).unwrap(); + let mut spotify_header = None; + + while let Some(item) = stream.next().await { + // Retrieve chunk. + let mut chunk = item?.to_vec(); + cipher.apply_keystream(&mut chunk); + if first { + spotify_header = Some(chunk.drain(0..167).collect::>()); + first = false; + } + file.write_all(&chunk).await?; + } + drop(file); + std::fs::rename(&tpath_tmp, &tpath)?; + + let norm_data = + spotify_header.and_then(|h| NormalisationData::parse_from_ogg(Cursor::new(&h)).ok()); + */ let (norm_data, file_size) = self.download_audio_file(audio_url, &key, &tpath).await?; let file_size = file_size as i64; @@ -800,36 +837,37 @@ impl SpotifyDownloader { } } - let track_id_str = sp_item.actual_id.as_ref().unwrap_or(&sp_item.track_id).id(); + let track_id_str = sp_item + .actual_id + .as_ref() + .unwrap_or(&sp_item.track_id) + .to_base62(); let audio_format_str = format!("{:?}", audio_format); let key_str = key.to_string(); let release_date_str = sp_item.release_date.as_ref().and_then(date_to_string); - let cover_url = sess.image_url(&cover.id); + let cover_url = sess.image_url(&cover.id)?; let album_type_str = album_type_str(track_fields.album_type); - if self.i.apply_tags { - if let Err(e) = tag_file( - &tpath, - track_id_str, - title, - &album_artist.name, - &track_fields.album_name, - track_fields.number, - track_fields.disc_number, - &artist.name, - &artists_json, - album_id_str, - Some(&album_artist.id), - release_date_str.as_deref(), - genre.as_deref(), - norm_data, - ) { - tracing::error!("error saving tags: {}", e); - } + if let Err(e) = tag_file( + &tpath, + &track_id_str, + title, + &album_artist.name, + &track_fields.album_name, + track_fields.number, + track_fields.disc_number, + &artist.name, + &artists_json, + &album_id_str, + Some(&album_artist.id), + release_date_str.as_deref(), + genre.as_deref(), + norm_data, + ) { + tracing::error!("error saving tags: {}", e); } if validate_file(&tpath).is_err() { - _ = std::fs::rename(&tpath, tpath.with_extension("ogg.broken")); return Err(Error::InvalidFile(tpath.to_string_lossy().to_string())); } tracing::info!( @@ -911,9 +949,9 @@ impl SpotifyDownloader { /// This is the case if the download is either /// - successful /// - unavailable - pub async fn download_track_log(&self, track_id: TrackId<'_>, force: bool) -> bool { + pub async fn download_track_log(&self, track_id: &SpotifyId, force: bool) -> bool { for _ in 0..3 { - match self.download_track(track_id.as_ref(), force).await { + match self.download_track(track_id, force).await { Ok(sp_item) => { if let Some(sp_item) = &sp_item { if let UniqueFields::Track(sp_track) = &sp_item.unique_fields { @@ -950,25 +988,9 @@ impl SpotifyDownloader { false } - pub async fn download_artist( - &self, - artist_id: ArtistId<'_>, - if_none_match: Option<&str>, - ) -> Result { - let artist_id_str = artist_id.id(); - let artist_res = self - .i - .sp - .spclient()? - .pb_artist(artist_id.as_ref(), if_none_match) - .await - .into_option()?; - - let artist_et = match artist_res { - Some(artist_et) => artist_et, - None => return Ok(true), - }; - let artist = artist_et.data; + pub async fn download_artist(&self, artist_id: &SpotifyId) -> Result { + let artist_id_str = artist_id.to_base62(); + let artist = self.i.sp.spclient()?.pb_artist(artist_id).await?; let artist_name = artist.name.unwrap_or_default(); let album_ids = artist .album_group @@ -976,7 +998,7 @@ impl SpotifyDownloader { .chain(artist.single_group) .chain(artist.compilation_group) .filter_map(|b| b.album.into_iter().next()) - .map(|b| AlbumId::from_raw(b.gid())) + .map(|b| SpotifyId::from_raw(b.gid()).map(SpotifyId::album)) .collect::, _>>()?; tracing::info!( @@ -988,16 +1010,15 @@ impl SpotifyDownloader { let mut success = true; for album_id in &album_ids { - success &= self.download_album(album_id.as_ref()).await?; + success &= self.download_album(album_id).await?; } if success { let now = OffsetDateTime::now_utc().unix_timestamp(); let res = sqlx::query!( - "update artists set last_update=$2, etag=$3 where id=$1", + "update artists set last_update=$2 where id=$1", artist_id_str, now, - artist_et.etag, ) .execute(&self.i.pool) .await; @@ -1012,15 +1033,9 @@ impl SpotifyDownloader { Ok(success) } - pub async fn download_album(&self, album_id: AlbumId<'_>) -> Result { - let album_id_str = album_id.id(); - let album = self - .i - .sp - .spclient()? - .pb_album(album_id.as_ref(), None) - .await? - .data; + pub async fn download_album(&self, album_id: &SpotifyId) -> Result { + let album_id_str = album_id.to_base62(); + let album = self.i.sp.spclient()?.pb_album(album_id).await?; let row = sqlx::query!(r#"select id from albums where id=$1"#, album_id_str) .fetch_optional(&self.i.pool) @@ -1033,13 +1048,13 @@ impl SpotifyDownloader { N_DISCS_CACHE .write() .unwrap() - .insert(album_id_str.to_owned(), n_discs); + .insert(album_id.to_base62().into_owned(), n_discs); let track_ids = album .disc .iter() .flat_map(|d| &d.track) - .map(|t| TrackId::from_raw(t.gid())) + .map(|t| SpotifyId::from_raw(t.gid()).map(SpotifyId::track)) .collect::, _>>()?; let success = Arc::new(AtomicBool::new(true)); @@ -1048,7 +1063,7 @@ impl SpotifyDownloader { .for_each_concurrent(4, |id| { let success = success.clone(); async move { - let ok = self.download_track_log(id.as_ref(), false).await; + let ok = self.download_track_log(id, false).await; if !ok { success.store(false, Ordering::SeqCst); } @@ -1083,18 +1098,18 @@ impl SpotifyDownloader { m.add(pb) }); sqlx::query!( - "select id, name, etag from artists where active=1 and (last_update is null or last_update < $1)", + "select id, name from artists where active=1 and (last_update is null or last_update < $1)", date_thr ) .fetch(&self.i.pool) .map_err(Error::from) - .try_for_each_concurrent(4, |row| { + .try_for_each_concurrent(4, |x| { if let Some(progress) = &progress { progress.inc(1); } + let artist_id = SpotifyId::from_base62(&x.id.unwrap()).unwrap().artist(); async move { - let artist_id = ArtistId::from_id(row.id.ok_or(Error::Other("no artist id".into()))?)?; - self.download_artist(artist_id, row.etag.as_deref()).await?; + self.download_artist(&artist_id).await?; Ok(()) } }) @@ -1171,7 +1186,7 @@ fn map_track(session: &Session, track: Track) -> Result { .ok_or(MetadataError::Missing("album"))?; Ok(AudioItem { - track_id: PlayableId::Track(TrackId::from_raw(track.gid())?), + track_id: SpotifyId::from_raw(track.gid())?.track(), actual_id: None, files: AudioFiles::from(track.file.as_slice()), covers: get_covers(album.cover_group.image.iter().chain(&album.cover)), @@ -1191,7 +1206,7 @@ fn map_track(session: &Session, track: Track) -> Result { .iter() .map(|track| { Ok(AlternativeTrack { - track_id: TrackId::from_raw(track.gid())?, + track_id: SpotifyId::from_raw(track.gid())?.track(), availability: availability( session, &track.availability, @@ -1203,7 +1218,7 @@ fn map_track(session: &Session, track: Track) -> Result { }) .collect::, Error>>()?, unique_fields: UniqueFields::Track(TrackUniqueFields { - album_id: AlbumId::from_raw(album.gid())?, + album_id: SpotifyId::from_raw(album.gid())?.album(), artists: track .artist_with_role .into_iter() @@ -1387,14 +1402,7 @@ fn tag_file( } fn validate_file(path: &Path) -> Result<(), Error> { - if path.extension().is_some_and(|ext| ext == "ogg") { - let res = Command::new("ogginfo").arg(path).output()?; - if !res.status.success() { - return Err(Error::InvalidFile( - String::from_utf8_lossy(&res.stdout).into_owned(), - )); - } - } + Command::new("ogginfo").arg(path).output()?; Ok(()) } diff --git a/crates/downloader/src/main.rs b/crates/downloader/src/main.rs index 0cf0a85..53dcbf2 100644 --- a/crates/downloader/src/main.rs +++ b/crates/downloader/src/main.rs @@ -1,5 +1,4 @@ use std::{ - collections::HashMap, num::NonZeroU32, path::PathBuf, sync::{ @@ -11,10 +10,11 @@ use std::{ use clap::{Parser, Subcommand}; use futures_util::{stream, StreamExt, TryStreamExt}; -use indicatif::{MultiProgress, ProgressBar}; +use indicatif::MultiProgress; +use reqwest::Url; use spotifyio::{ - model::{AlbumId, ArtistId, Id, IdConstruct, SearchResult, SearchType, TrackId}, - ApplicationCache, AuthCredentials, AuthenticationType, Quota, Session, SessionConfig, + ApplicationCache, AuthCredentials, AuthenticationType, Quota, SearchResult, SearchType, + Session, SessionConfig, SpotifyId, }; use spotifyio_downloader::{Error, SpotifyDownloader, SpotifyDownloaderConfig}; use tracing::level_filters::LevelFilter; @@ -51,8 +51,8 @@ struct Cli { #[clap(long, default_value = "spotifyio.json")] cache: PathBuf, /// Path to music library base directory - #[clap(long, default_value = "Downloads")] - base_dir: PathBuf, + #[clap(long)] + base_dir: Option, /// Path to SQLite database #[clap(long, default_value = "tracks.db")] db: PathBuf, @@ -62,9 +62,6 @@ struct Cli { /// Path to Widevine device file (from pywidevine) #[clap(long)] wvd: Option, - #[clap(long)] - /// Dont apply audio tags - no_tags: bool, /// Download tracks in m4a format #[clap(long)] m4a: bool, @@ -87,9 +84,7 @@ enum Commands { /// Migrate database Migrate, /// Add a new artist - AddArtist { - artist: Vec, - }, + AddArtist { artist: Vec }, /// Download new music DlNew { #[clap(long, default_value = "12h")] @@ -100,20 +95,15 @@ enum Commands { navidrome_url: Option, }, /// Download all albums of an artist - DlArtist { - artist: Vec, - }, + DlArtist { artist: Vec }, /// Download an album - DlAlbum { - album: Vec, - }, + DlAlbum { album: Vec }, /// Download a track DlTrack { track: Vec, #[clap(long)] force: bool, }, - ScrapeAudioFeatures, } #[tokio::main] @@ -188,6 +178,9 @@ async fn account_mgmt(cli: Cli) -> Result<(), Error> { } async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { + let base_dir = cli + .base_dir + .ok_or(Error::InvalidInput("base_dir missing"))?; let quota = match cli.quota { Some(q) => parse_quota(&q)?, None => Quota::per_minute(NonZeroU32::new(2).unwrap()), @@ -196,9 +189,8 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { cache_path: cli.cache, quota, db_path: cli.db, - base_dir: cli.base_dir, - progress: Some(multi.clone()), - apply_tags: !cli.no_tags, + base_dir, + progress: Some(multi), format_m4a: cli.m4a, widevine_device: cli.wvd, ..Default::default() @@ -209,15 +201,15 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { dl.migrate().await?; } Commands::AddArtist { artist } => { - let parsed = parse_url_ids::(artist.iter()); + let parsed = parse_url_ids(&artist); let artists = if let Ok(parsed) = parsed { - let got_artists = dl.session()?.spclient().pb_artists(parsed).await?; + let got_artists = dl.session()?.spclient().pb_artists(parsed.iter()).await?; got_artists - .iter() + .values() .map(|a| { Ok(spotifyio_downloader::model::ArtistItem { - id: ArtistId::from_raw(a.gid())?.into_id(), + id: SpotifyId::from_raw(a.gid())?.to_base62().into_owned(), name: a.name().to_owned(), }) }) @@ -232,7 +224,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { if let SearchResult::Artists(res) = res { for (i, a) in res.items.iter().enumerate() { - println!("{}: {} [{}]", i, a.name, a.id.id()); + println!("{}: {} [{}]", i, a.name, a.id.to_base62()); } let stdin = std::io::stdin(); let mut buf = String::new(); @@ -246,7 +238,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { .get(i) .ok_or(Error::InvalidInput("invalid number"))?; vec![spotifyio_downloader::model::ArtistItem { - id: a.id.id().to_owned(), + id: a.id.to_base62().into_owned(), name: a.name.to_owned(), }] } else { @@ -284,7 +276,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { .await?; } Commands::DlArtist { artist } => { - let artist_ids = parse_url_ids::(&artist)?; + let artist_ids = parse_url_ids(&artist)?; let success = Arc::new(AtomicBool::new(true)); stream::iter(artist_ids) .map(Ok::<_, Error>) @@ -292,7 +284,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { let dl = dl.clone(); let success = success.clone(); async move { - let ok = dl.download_artist(id.as_ref(), None).await?; + let ok = dl.download_artist(&id.artist()).await?; if !ok { success.store(false, Ordering::SeqCst); } @@ -307,7 +299,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { } } Commands::DlAlbum { album } => { - let album_ids = parse_url_ids::(&album)?; + let album_ids = parse_url_ids(&album)?; let success = Arc::new(AtomicBool::new(true)); stream::iter(album_ids) .map(Ok::<_, Error>) @@ -315,7 +307,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { let dl = dl.clone(); let success = success.clone(); async move { - let ok = dl.download_album(id).await?; + let ok = dl.download_album(&id.album()).await?; if !ok { success.store(false, Ordering::SeqCst); } @@ -330,14 +322,14 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { } } Commands::DlTrack { track, force } => { - let track_ids = parse_url_ids::(&track)?; + let track_ids = parse_url_ids(&track)?; let success = Arc::new(AtomicBool::new(true)); stream::iter(track_ids) .for_each_concurrent(8, |id| { let dl = dl.clone(); let success = success.clone(); async move { - let ok = dl.download_track_log(id.as_ref(), force).await; + let ok = dl.download_track_log(&id.track(), force).await; if !ok { success.store(false, Ordering::SeqCst); } @@ -350,64 +342,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { )); } } - Commands::ScrapeAudioFeatures => { - let n = - sqlx::query_scalar!(r#"select count(*) from tracks where audio_features is null"#) - .fetch_one(dl.pool()) - .await?; - let pb = multi.add(ProgressBar::new(n as u64)); - - loop { - let track_ids = sqlx::query_scalar!( - r#"select id from tracks where audio_features is null limit 50"# - ) - .fetch_all(dl.pool()) - .await?; - if track_ids.is_empty() { - break; - } - let track_ids_iter = track_ids - .iter() - .flatten() - .filter_map(|id| TrackId::from_id(id).ok()); - - let features = dl - .session()? - .spclient() - .web_tracks_features(track_ids_iter) - .await?; - - let features_map = features - .into_iter() - .map(|f| (f.id.id().to_owned(), f)) - .collect::>(); - - for id in track_ids.iter().flatten() { - match features_map.get(id) { - Some(f) => { - let af_json = serde_json::to_string(&f).unwrap(); - sqlx::query!( - r#"update tracks set audio_features=$2 where id=$1"#, - id, - af_json - ) - .execute(dl.pool()) - .await?; - } - None => { - sqlx::query!( - r#"update tracks set audio_features='null' where id=$1"#, - id, - ) - .execute(dl.pool()) - .await?; - } - } - } - pb.inc(track_ids.len() as u64); - } - } - Commands::Accounts | Commands::Login | Commands::Logout { .. } => unreachable!(), + _ => unreachable!(), } dl.shutdown().await; Ok(()) @@ -434,11 +369,20 @@ fn parse_quota(s: &str) -> Result { Ok(quota) } -fn parse_url_ids<'a, T: IdConstruct<'a>>( - s: impl IntoIterator, -) -> Result, Error> { +fn parse_url_or_id(s: &str) -> Result { + if let Ok(id) = SpotifyId::from_id_or_uri(s) { + return Ok(id); + } + + let url = Url::parse(s).map_err(|_| ())?; + tracing::debug!("url: {url:?}"); + let pstr = format!("spotify{}", url.path()); + SpotifyId::from_uri(&pstr).map_err(|_| ()) +} + +fn parse_url_ids>(s: impl IntoIterator) -> Result, Error> { s.into_iter() - .map(|s| T::from_id_uri_or_url(s)) - .collect::, _>>() + .map(|s| parse_url_or_id(s.as_ref())) + .collect::, _>>() .map_err(|_| Error::InvalidInput("ids")) } diff --git a/crates/downloader/src/model/audio_file.rs b/crates/downloader/src/model/audio_file.rs index 3749b80..e8f8ca3 100644 --- a/crates/downloader/src/model/audio_file.rs +++ b/crates/downloader/src/model/audio_file.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, fmt::Debug}; -use spotifyio::{model::FileId, pb::AudioFileFormat}; +use spotifyio::{pb::AudioFileFormat, FileId}; use spotifyio_protocol::metadata::AudioFile as AudioFileMessage; #[derive(Debug, Clone, Default)] diff --git a/crates/downloader/src/model/mod.rs b/crates/downloader/src/model/mod.rs index 074e15f..00779cd 100644 --- a/crates/downloader/src/model/mod.rs +++ b/crates/downloader/src/model/mod.rs @@ -1,8 +1,7 @@ use serde::Serialize; use spotifyio::{ - model::{AlbumId, ArtistId, FileId, IdConstruct, PlayableId, TrackId}, pb::{ArtistRole as PbArtistRole, ImageSize}, - Error, + Error, FileId, SpotifyId, }; use spotifyio_protocol::metadata::{ album::Type as AlbumType, Artist as ArtistMessage, ArtistWithRole as ArtistWithRoleMessage, @@ -26,8 +25,8 @@ pub struct CoverImage { #[derive(Debug, Clone)] pub struct AudioItem { - pub track_id: PlayableId<'static>, - pub actual_id: Option>, + pub track_id: SpotifyId, + pub actual_id: Option, pub files: AudioFiles, pub name: String, pub covers: Vec, @@ -43,7 +42,7 @@ pub struct AudioItem { #[derive(Debug, Clone)] pub struct TrackUniqueFields { pub artists: Vec, - pub album_id: AlbumId<'static>, + pub album_id: SpotifyId, pub album_name: String, pub album_artists: Vec, pub album_type: AlbumType, @@ -66,7 +65,7 @@ pub enum UniqueFields { #[derive(Debug, Clone)] pub struct AlternativeTrack { - pub track_id: TrackId<'static>, + pub track_id: SpotifyId, pub availability: AudioItemAvailability, pub files: AudioFiles, } @@ -88,7 +87,9 @@ impl TryFrom for ArtistWithRole { type Error = Error; fn try_from(value: ArtistWithRoleMessage) -> Result { Ok(Self { - id: ArtistId::from_raw(value.artist_gid())?.into_id(), + id: SpotifyId::from_raw(value.artist_gid())? + .to_base62() + .into_owned(), role: value.role().into(), name: value.artist_name.unwrap_or_default(), }) @@ -100,7 +101,7 @@ impl TryFrom for ArtistItem { fn try_from(value: ArtistMessage) -> Result { Ok(Self { - id: ArtistId::from_raw(value.gid())?.into_id(), + id: SpotifyId::from_raw(value.gid())?.to_base62().into_owned(), name: value.name.unwrap_or_default(), }) } diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index ae1a727..0aeee7b 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spotifyio-model" -version = "0.2.0" +version = "0.1.0" authors = [ "Ramsay Leung ", "Mario Ortiz Manero ", @@ -20,5 +20,3 @@ strum = { version = "0.26.1", features = ["derive"] } thiserror = "2" time = { version = "0.3.21", features = ["serde-well-known"] } data-encoding = "2.5" -urlencoding = "2.1.0" -url = "2" diff --git a/crates/model/src/album.rs b/crates/model/src/album.rs index 48386d1..30084c5 100644 --- a/crates/model/src/album.rs +++ b/crates/model/src/album.rs @@ -6,8 +6,8 @@ use time::OffsetDateTime; use std::collections::HashMap; use crate::{ - AlbumId, AlbumType, Copyright, DatePrecision, Image, Page, RestrictionReason, SimplifiedArtist, - SimplifiedTrack, + AlbumType, Copyright, DatePrecision, Image, Page, RestrictionReason, SimplifiedArtist, + SimplifiedTrack, SpotifyId, }; /// Simplified Album Object @@ -20,7 +20,8 @@ pub struct SimplifiedAlbum { #[serde(skip_serializing_if = "Vec::is_empty", default)] pub available_markets: Vec, pub href: Option, - pub id: Option>, + #[serde(with = "crate::spotify_id::ser::album::option")] + pub id: Option, pub images: Vec, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -41,7 +42,8 @@ pub struct FullAlbum { pub external_ids: HashMap, pub genres: Vec, pub href: String, - pub id: AlbumId<'static>, + #[serde(with = "crate::spotify_id::ser::album")] + pub id: SpotifyId, pub images: Vec, pub name: String, pub popularity: u32, diff --git a/crates/model/src/artist.rs b/crates/model/src/artist.rs index bea3439..36d033a 100644 --- a/crates/model/src/artist.rs +++ b/crates/model/src/artist.rs @@ -2,13 +2,14 @@ use serde::{Deserialize, Serialize}; -use crate::{ArtistId, CursorBasedPage, Followers, Image}; +use crate::{CursorBasedPage, Followers, Image, SpotifyId}; /// Simplified Artist Object #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct SimplifiedArtist { pub href: Option, - pub id: Option>, + #[serde(with = "crate::spotify_id::ser::artist::option")] + pub id: Option, pub name: String, } @@ -18,7 +19,8 @@ pub struct FullArtist { pub followers: Followers, pub genres: Vec, pub href: String, - pub id: ArtistId<'static>, + #[serde(with = "crate::spotify_id::ser::artist")] + pub id: SpotifyId, pub images: Vec, pub name: String, pub popularity: u32, diff --git a/crates/model/src/audio.rs b/crates/model/src/audio.rs index dab7d8d..521be4b 100644 --- a/crates/model/src/audio.rs +++ b/crates/model/src/audio.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -use crate::{custom_serde::modality, Modality, TrackId}; +use crate::{custom_serde::modality, Modality, SpotifyId}; /// Audio Feature Object #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -12,7 +12,8 @@ pub struct AudioFeatures { pub danceability: f32, pub duration_ms: u32, pub energy: f32, - pub id: TrackId<'static>, + #[serde(with = "crate::spotify_id::ser::track")] + pub id: SpotifyId, pub instrumentalness: f32, pub key: i32, pub liveness: f32, diff --git a/crates/model/src/context.rs b/crates/model/src/context.rs index 923a11e..172c1e5 100644 --- a/crates/model/src/context.rs +++ b/crates/model/src/context.rs @@ -5,7 +5,7 @@ use time::OffsetDateTime; use std::collections::HashMap; -use crate::{CurrentlyPlayingType, Device, DisallowKey, PlayableItem, RepeatState, SpotifyType}; +use crate::{CurrentlyPlayingType, Device, DisallowKey, PlayableItem, RepeatState, Type}; /// Context object #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -14,7 +14,7 @@ pub struct Context { pub uri: String, pub href: String, #[serde(rename = "type")] - pub _type: SpotifyType, + pub _type: Type, } /// Currently playing object diff --git a/crates/model/src/enums/types.rs b/crates/model/src/enums/types.rs index 8c94970..147c656 100644 --- a/crates/model/src/enums/types.rs +++ b/crates/model/src/enums/types.rs @@ -30,7 +30,7 @@ pub enum AlbumType { )] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] -pub enum SpotifyType { +pub enum Type { Artist, Album, Track, @@ -40,10 +40,8 @@ pub enum SpotifyType { Episode, Collection, Collectionyourepisodes, // rename to collectionyourepisodes - Local, Concert, Prerelease, - Songwriter, } /// Additional typs: `track`, `episode` diff --git a/crates/model/src/file_id.rs b/crates/model/src/file_id.rs index 5d63bb5..e4a5d22 100644 --- a/crates/model/src/file_id.rs +++ b/crates/model/src/file_id.rs @@ -2,7 +2,7 @@ use std::fmt; use data_encoding::HEXLOWER_PERMISSIVE; -use crate::IdError; +use crate::{spotify_id::to_base16, IdError}; #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct FileId(pub [u8; 20]); @@ -25,20 +25,21 @@ impl FileId { Ok(FileId(dst)) } - pub fn base16(&self) -> String { - HEXLOWER_PERMISSIVE.encode(&self.0) + #[allow(clippy::wrong_self_convention)] + pub fn to_base16(&self) -> Result { + to_base16(&self.0, &mut [0u8; 40]) } } impl fmt::Debug for FileId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("FileId").field(&self.base16()).finish() + f.debug_tuple("FileId").field(&self.to_base16()).finish() } } impl fmt::Display for FileId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.base16()) + f.write_str(&self.to_base16().unwrap_or_default()) } } diff --git a/crates/model/src/idtypes.rs b/crates/model/src/idtypes.rs deleted file mode 100644 index 708ce69..0000000 --- a/crates/model/src/idtypes.rs +++ /dev/null @@ -1,1154 +0,0 @@ -//! This module makes it possible to represent Spotify IDs and URIs with type -//! safety and almost no overhead. -//! -//! ## Concrete IDs -//! -//! The trait [`Id`] is the central element of this module. It's implemented by -//! all kinds of ID, and includes the main functionality to use them. Remember -//! that you will need to import this trait to access its methods. The easiest -//! way is to add `use rspotify::prelude::*`. -//! -//! * [`SpotifyType::Artist`] => [`ArtistId`] -//! * [`SpotifyTypeType::Album`] => [`AlbumId`] -//! * [`SpotifyTypeType::Track`] => [`TrackId`] -//! * [`SpotifyTypeType::Playlist`] => [`PlaylistId`] -//! * [`SpotifyTypeType::User`] => [`UserId`] -//! * [`SpotifyTypeType::Show`] => [`ShowId`] -//! * [`SpotifyTypeType::Episode`] => [`EpisodeId`] -//! -//! Every kind of ID defines its own validity function, i.e., what characters it -//! can be made up of, such as alphanumeric or any. -//! -//! These types are just wrappers for [`Cow`], so their usage should be -//! quite similar overall. -//! -//! [`Cow`]: [`std::borrow::Cow`] -//! -//! ## Examples -//! -//! If an endpoint requires a `TrackId`, you may pass it as: -//! -//! ``` -//! # use rspotify_model::TrackId; -//! fn pause_track(id: TrackId<'_>) { /* ... */ } -//! -//! let id = TrackId::from_id("4iV5W9uYEdYUVa79Axb7Rh").unwrap(); -//! pause_track(id); -//! ``` -//! -//! Notice how this way it's type safe; the following example would fail at -//! compile-time: -//! -//! ```compile_fail -//! # use rspotify_model::{TrackId, EpisodeId}; -//! fn pause_track(id: TrackId<'_>) { /* ... */ } -//! -//! let id = EpisodeId::from_id("4iV5W9uYEdYUVa79Axb7Rh").unwrap(); -//! pause_track(id); -//! ``` -//! -//! And this would panic because it's a `TrackId` but its URI string specifies -//! it's an album (`spotify:album:xxxx`). -//! -//! ```should_panic -//! # use rspotify_model::TrackId; -//! fn pause_track(id: TrackId<'_>) { /* ... */ } -//! -//! let id = TrackId::from_uri("spotify:album:6akEvsycLGftJxYudPjmqK").unwrap(); -//! pause_track(id); -//! ``` -//! -//! A more complex example where an endpoint takes a vector of IDs of different -//! types: -//! -//! ``` -//! use rspotify_model::{TrackId, EpisodeId, PlayableId}; -//! -//! fn track(id: TrackId<'_>) { /* ... */ } -//! fn episode(id: EpisodeId<'_>) { /* ... */ } -//! fn add_to_queue(id: &[PlayableId<'_>]) { /* ... */ } -//! -//! let tracks = [ -//! TrackId::from_uri("spotify:track:4iV5W9uYEdYUVa79Axb7Rh").unwrap(), -//! TrackId::from_uri("spotify:track:5iKndSu1XI74U2OZePzP8L").unwrap(), -//! ]; -//! let episodes = [ -//! EpisodeId::from_id("0lbiy3LKzIY2fnyjioC11p").unwrap(), -//! EpisodeId::from_id("4zugY5eJisugQj9rj8TYuh").unwrap(), -//! ]; -//! -//! // First we get some info about the tracks and episodes -//! let track_info = tracks.iter().map(|id| track(id.as_ref())).collect::>(); -//! let ep_info = episodes.iter().map(|id| episode(id.as_ref())).collect::>(); -//! println!("Track info: {:?}", track_info); -//! println!("Episode info: {:?}", ep_info); -//! -//! // And then we add both the tracks and episodes to the queue -//! let playable = tracks -//! .into_iter() -//! .map(|t| t.as_ref().into()) -//! .chain( -//! episodes.into_iter().map(|e| e.as_ref().into()) -//! ) -//! .collect::>(); -//! add_to_queue(&playable); -//! ``` - -use enum_dispatch::enum_dispatch; -use serde::{Deserialize, Serialize}; -use strum::Display; -use thiserror::Error; -use url::Url; - -use std::{borrow::Cow, fmt::Debug, hash::Hash}; - -use crate::SpotifyType; - -const SIZE_BASE62: usize = 22; - -/// Spotify ID or URI parsing error -/// -/// See also [`Id`] for details. -#[derive(Debug, PartialEq, Eq, Clone, Copy, Display, Error)] -pub enum IdError { - /// Spotify URI prefix is not `spotify:` or `spotify/`. - InvalidPrefix, - /// Spotify URI can't be split into type and id parts (e.g., it has invalid - /// separator). - InvalidFormat, - /// Spotify URI has invalid type name, or id has invalid type in a given - /// context (e.g. a method expects a track id, but artist id is provided). - InvalidType, - /// Spotify id is invalid (empty or contains invalid characters). - InvalidId, -} - -/// The main interface for an ID. -/// -/// See the [module level documentation] for more information. -/// -/// [module level documentation]: [`crate::idtypes`] -#[enum_dispatch] -pub trait Id { - /// Returns the inner Spotify object ID, which is guaranteed to be valid for - /// its type. - fn id(&self) -> &str; - - /// The type of the ID, as a function. - fn spotify_type(&self) -> SpotifyType; - - /// Returns a Spotify object URI in a well-known format: `spotify:type:id`. - /// - /// Examples: `spotify:album:6IcGNaXFRf5Y1jc7QsE9O2`, - /// `spotify:track:4y4VO05kYgUTo2bzbox1an`. - fn uri(&self) -> String { - let id = if self.spotify_type() == SpotifyType::User { - urlencoding::encode(self.id()) - } else { - self.id().into() - }; - format!("spotify:{}:{}", self.spotify_type(), id) - } - - /// Returns a full Spotify object URL that can be opened in a browser. - /// - /// Examples: `https://open.spotify.com/track/4y4VO05kYgUTo2bzbox1an`, - /// `https://open.spotify.com/artist/2QI8e2Vwgg9KXOz2zjcrkI`. - fn url(&self) -> String { - format!( - "https://open.spotify.com/{}/{}", - self.spotify_type(), - self.id() - ) - } -} - -/// IDs that only use Spotify's base62 format -pub trait IdBase62: Id { - /// Returns the Spotify ID as an array of 16 bytes in big-endian order. - fn raw(&self) -> [u8; 16] { - parse_base62(self.id()).unwrap() - } - - /// Returns the SpotifyId as a base16 (hex) encoded, 32 character long string. - fn base16(&self) -> String { - let src = parse_base62(self.id()).unwrap(); - data_encoding::HEXLOWER_PERMISSIVE.encode(&src) - } -} - -pub trait IdConstruct<'a>: Sized { - /// Initialize the ID without checking its validity. - /// - /// # Safety - /// - /// The string passed to this method must be made out of valid - /// characters only; otherwise undefined behaviour may occur. - unsafe fn from_id_unchecked(id: S) -> Self - where - S: Into>; - - /// The type of the ID (statically associated with the object type) - fn spotify_type_static() -> SpotifyType; - - /// Only returns `true` in case the given string is valid - /// according to that specific ID (e.g., some may require - /// alphanumeric characters only). - #[must_use] - fn id_is_valid(id: &str) -> bool { - match Self::spotify_type_static() { - SpotifyType::User | SpotifyType::Local => { - !id.is_empty() && !id.contains(['/', '&', '@', '\'', '"']) - } - _ => parse_base62(id).is_ok(), - } - } - - /// Parse Spotify ID from string slice. - /// - /// A valid Spotify object id must be a non-empty string with - /// valid characters. - /// - /// # Errors - /// - /// - `IdError::InvalidId` - if `id` contains invalid characters. - fn from_id(id: S) -> Result - where - S: Into>, - { - let id = id.into(); - if Self::id_is_valid(&id) { - // Safe, we've just checked that the ID is valid. - Ok(unsafe { Self::from_id_unchecked(id) }) - } else { - Err(IdError::InvalidId) - } - } - - /// Parse Spotify URI from string slice - /// - /// Spotify URI must be in one of the following formats: - /// `spotify:{type}:{id}` or `spotify/{type}/{id}`. - /// Where `{type}` is one of `artist`, `album`, `track`, - /// `playlist`, `user`, `show`, or `episode`, and `{id}` is a - /// non-empty valid string. - /// - /// Examples: `spotify:album:6IcGNaXFRf5Y1jc7QsE9O2`, - /// `spotify/track/4y4VO05kYgUTo2bzbox1an`. - /// - /// # Errors - /// - /// - `IdError::InvalidPrefix` - if `uri` is not started with - /// `spotify:` or `spotify/`, - /// - `IdError::InvalidType` - if type part of an `uri` is not a - /// valid Spotify type `T`, - /// - `IdError::InvalidId` - if id part of an `uri` is not a - /// valid id, - /// - `IdError::InvalidFormat` - if it can't be splitted into - /// type and id parts. - /// - /// # Implementation details - /// - /// Unlike [`Self::from_id`], this method takes a `&str` rather - /// than an `Into>`. This is because the inner `Cow` in - /// the ID would reference a slice from the given `&str` (i.e., - /// taking the ID out of the URI). The parameter wouldn't live - /// long enough when using `Into>`, so the only - /// sensible choice is to just use a `&str`. - fn from_uri(uri: &'a str) -> Result { - let (tpe, id) = parse_uri(uri)?; - if tpe == Self::spotify_type_static() { - Self::from_id(id) - } else { - Err(IdError::InvalidType) - } - } - - /// Parse Spotify ID or URI from string slice - /// - /// Spotify URI must be in one of the following formats: - /// `spotify:{type}:{id}` or `spotify/{type}/{id}`. - /// Where `{type}` is one of `artist`, `album`, `track`, - /// `playlist`, `user`, `show`, or `episode`, and `{id}` is a - /// non-empty valid string. The URI must be match with the ID's - /// type (`Id::TYPE`), otherwise `IdError::InvalidType` error is - /// returned. - /// - /// Examples: `spotify:album:6IcGNaXFRf5Y1jc7QsE9O2`, - /// `spotify/track/4y4VO05kYgUTo2bzbox1an`. - /// - /// If input string is not a valid Spotify URI (it's not started - /// with `spotify:` or `spotify/`), it must be a valid Spotify - /// object ID, i.e. a non-empty valid string. - /// - /// # Errors - /// - /// - `IdError::InvalidType` - if `id_or_uri` is an URI, and - /// it's type part is not equal to `T`, - /// - `IdError::InvalidId` - either if `id_or_uri` is an URI - /// with invalid id part, or it's an invalid id (id is invalid - /// if it contains valid characters), - /// - `IdError::InvalidFormat` - if `id_or_uri` is an URI, and - /// it can't be split into type and id parts. - /// - /// # Implementation details - /// - /// Unlike [`Self::from_id`], this method takes a `&str` rather - /// than an `Into>`. This is because the inner `Cow` in - /// the ID would reference a slice from the given `&str` (i.e., - /// taking the ID out of the URI). The parameter wouldn't live - /// long enough when using `Into>`, so the only - /// sensible choice is to just use a `&str`. - fn from_id_or_uri(id_or_uri: &'a str) -> Result { - match Self::from_uri(id_or_uri) { - Ok(id) => Ok(id), - Err(IdError::InvalidPrefix) => Self::from_id(id_or_uri), - Err(error) => Err(error), - } - } - - /// Parse Spotify ID from a spotify.com URL - /// - /// Example: `/artist/1EfwyuCzDQpCslZc8C9gkG` - /// - /// The leading slash is required. - fn from_url(url: &'a str) -> Result { - let url = Url::parse(url).map_err(|_| IdError::InvalidFormat)?; - let (tpe, id) = parse_uri_noprefix(url.path())?; - if tpe == Self::spotify_type_static() { - Self::from_id(id.into_owned()) - } else { - Err(IdError::InvalidType) - } - } - - /// Parse Spotify ID, URI or URL from string slice - fn from_id_uri_or_url(id_uri_or_url: &'a str) -> Result { - match Self::from_url(id_uri_or_url) { - Ok(id) => Ok(id), - Err(IdError::InvalidFormat) => Self::from_id_or_uri(id_uri_or_url), - Err(error) => Err(error), - } - } - - /// Parses a base16 (hex) encoded Spotify ID into a `SpotifyId`. - /// - /// `id` is expected to be 32 bytes long and encoded using valid characters. - fn from_base16(id: &str) -> Result { - let bytes = data_encoding::HEXLOWER_PERMISSIVE - .decode(id.as_bytes()) - .map_err(|_| IdError::InvalidId)?; - Self::from_raw(&bytes) - } - - /// Parses a Spotify ID from an array of 16 bytes in big-endian order. - fn from_raw(id: &[u8]) -> Result { - let s = raw_to_base62(id.try_into().map_err(|_| IdError::InvalidId)?); - Ok(unsafe { Self::from_id_unchecked(s) }) - } -} - -/// A lower level function to parse a b62-encoded Spotify ID into an u128 -/// -/// `src` is expected to be 22 bytes long and encoded using valid characters. -fn parse_base62(src: &str) -> Result<[u8; 16], IdError> { - if src.len() != SIZE_BASE62 { - return Err(IdError::InvalidId); - } - 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(IdError::InvalidId), - } as u128; - - dst = dst.checked_mul(62).ok_or(IdError::InvalidId)?; - dst = dst.checked_add(p).ok_or(IdError::InvalidId)?; - } - Ok(dst.to_be_bytes()) -} - -const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; - -fn raw_to_base62(raw: [u8; 16]) -> String { - let n = u128::from_be_bytes(raw); - let mut dst = [0u8; 22]; - let mut i = 0; - - // 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()).unwrap() -} - -/// A lower level function to parse a URI into both its type and its actual ID. -/// Note that this function doesn't check the validity of the returned ID (e.g., -/// whether it's alphanumeric; that should be done in `Id::from_id`). -/// -/// This is only useful for advanced use-cases, such as implementing your own ID -/// type. -pub fn parse_uri(uri: &str) -> Result<(SpotifyType, Cow<'_, str>), IdError> { - let s = uri.strip_prefix("spotify").ok_or(IdError::InvalidPrefix)?; - parse_uri_noprefix(s) -} - -fn parse_uri_noprefix(uri: &str) -> Result<(SpotifyType, Cow<'_, str>), IdError> { - let mut chars = uri.chars(); - let sep = match chars.next() { - Some(ch) if ch == '/' || ch == ':' => ch, - _ => return Err(IdError::InvalidPrefix), - }; - let rest = chars.as_str(); - - let (tpe, id) = rest - .find(sep) - .map(|mid| rest.split_at(mid)) - .ok_or(IdError::InvalidFormat)?; - - // Note that in case the type isn't known at compile time, - // any type will be accepted. - let tpe_dec = tpe - .parse::() - .map_err(|_| IdError::InvalidType)?; - let id_dec = if tpe_dec == SpotifyType::User { - urlencoding::decode(&id[1..]).map_err(|_| IdError::InvalidType)? - } else { - id[1..].into() - }; - Ok((tpe_dec, id_dec)) -} - -/// This macro helps consistently define ID types. -/// -/// * The `$type` parameter indicates what variant in [`SpotifyType`] the ID is for (say, -/// `Artist`, or `Album`). -/// * The `$name` parameter is the identifier of the struct. -/// * The `$validity` parameter is the implementation of `id_is_valid`. -macro_rules! define_idtypes { - ($($type:ident => $name:ident),+) => { - $( - #[doc = concat!( - "ID of type [`Type::", stringify!($type), "`].\n\nRefer to the [module-level \ - docs][`crate::idtypes`] for more information. " - )] - #[repr(transparent)] - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)] - pub struct $name<'a>(Cow<'a, str>); - - impl<'a> $name<'a> { - const TYPE: SpotifyType = SpotifyType::$type; - - /// This creates an ID with the underlying `&str` variant from a - /// reference. Useful to use an ID multiple times without having - /// to clone it. - #[must_use] - pub fn as_ref(&'a self) -> Self { - Self(Cow::Borrowed(self.0.as_ref())) - } - - /// An ID is a `Cow` after all, so this will switch to the its - /// owned version, which has a `'static` lifetime. - #[must_use] - pub fn into_static(self) -> $name<'static> { - $name(Cow::Owned(self.0.into_owned())) - } - - /// Similar to [`Self::into_static`], but without consuming the - /// original ID. - #[must_use] - pub fn clone_static(&self) -> $name<'static> { - $name(Cow::Owned(self.0.clone().into_owned())) - } - - /// Consume the Spotify ID and return the inner Spotify Object ID - /// as a String. - pub fn into_id(self) -> String { - self.0.into() - } - } - - impl<'a> IdConstruct<'a> for $name<'a> { - unsafe fn from_id_unchecked(id: S) -> Self - where - S: Into> - { - Self(id.into()) - } - - fn spotify_type_static() -> SpotifyType { - Self::TYPE - } - } - - impl Id for $name<'_> { - fn id(&self) -> &str { - &self.0 - } - - fn spotify_type(&self) -> SpotifyType { - Self::TYPE - } - } - - // Deserialization may take either an ID or an URI, so its - // implementation has to be done manually. - impl<'de> Deserialize<'de> for $name<'static> { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct IdVisitor; - - impl<'de> serde::de::Visitor<'de> for IdVisitor { - type Value = $name<'static>; - - fn expecting( - &self, formatter: &mut std::fmt::Formatter<'_> - ) -> Result<(), std::fmt::Error> - { - let msg = concat!("ID or URI for struct ", stringify!($name)); - formatter.write_str(msg) - } - - fn visit_str(self, value: &str) -> Result - where - E: serde::de::Error, - { - $name::from_id_or_uri(value) - .map($name::into_static) - .map_err(serde::de::Error::custom) - } - - fn visit_newtype_struct( - self, - deserializer: A, - ) -> Result - where - A: serde::Deserializer<'de>, - { - deserializer.deserialize_str(self) - } - - fn visit_seq( - self, - mut seq: A, - ) -> Result - where - A: serde::de::SeqAccess<'de>, - { - let field: &str = seq.next_element()? - .ok_or_else(|| serde::de::Error::invalid_length(0, &self))?; - $name::from_id_or_uri(field) - .map($name::into_static) - .map_err(serde::de::Error::custom) - } - } - - deserializer.deserialize_newtype_struct(stringify!($name), IdVisitor) - } - } - - /// `Id`s may be borrowed as `str` the same way `Box` may be - /// borrowed as `T` or `String` as `str` - impl std::borrow::Borrow for $name<'_> { - fn borrow(&self) -> &str { - self.id() - } - } - - /// Displaying the ID shows its URI - impl std::fmt::Display for $name<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self.id()) - } - } - )+ - } -} - -// First declaring the regular IDs. Those with custom behaviour will have to be -// declared manually later on. -define_idtypes!( - Artist => ArtistId, - Album => AlbumId, - Track => TrackId, - Playlist => PlaylistId, - Show => ShowId, - Episode => EpisodeId, - Concert => ConcertId, - Prerelease => PrereleaseId, - Songwriter => SongwriterId, - User => UserId, - Local => LocalId -); - -impl IdBase62 for ArtistId<'_> {} -impl IdBase62 for AlbumId<'_> {} -impl IdBase62 for TrackId<'_> {} -impl IdBase62 for PlaylistId<'_> {} -impl IdBase62 for ShowId<'_> {} -impl IdBase62 for EpisodeId<'_> {} -impl IdBase62 for ConcertId<'_> {} -impl IdBase62 for PrereleaseId<'_> {} -impl IdBase62 for SongwriterId<'_> {} - -// We use `enum_dispatch` for dynamic dispatch, which is not only easier to use -// than `dyn`, but also more efficient. - -// Grouping up multiple kinds of IDs to treat them generically. This also -// implements [`Id`], and [`From`] to instantiate it. - -#[enum_dispatch(Id)] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)] -pub enum PlayContextId<'a> { - Artist(ArtistId<'a>), - Album(AlbumId<'a>), - Playlist(PlaylistId<'a>), - Show(ShowId<'a>), -} -// These don't work with `enum_dispatch`, unfortunately. -impl<'a> PlayContextId<'a> { - #[must_use] - pub fn as_ref(&'a self) -> Self { - match self { - PlayContextId::Artist(x) => PlayContextId::Artist(x.as_ref()), - PlayContextId::Album(x) => PlayContextId::Album(x.as_ref()), - PlayContextId::Playlist(x) => PlayContextId::Playlist(x.as_ref()), - PlayContextId::Show(x) => PlayContextId::Show(x.as_ref()), - } - } - - #[must_use] - pub fn into_static(self) -> PlayContextId<'static> { - match self { - PlayContextId::Artist(x) => PlayContextId::Artist(x.into_static()), - PlayContextId::Album(x) => PlayContextId::Album(x.into_static()), - PlayContextId::Playlist(x) => PlayContextId::Playlist(x.into_static()), - PlayContextId::Show(x) => PlayContextId::Show(x.into_static()), - } - } - - #[must_use] - pub fn clone_static(&'a self) -> PlayContextId<'static> { - match self { - PlayContextId::Artist(x) => PlayContextId::Artist(x.clone_static()), - PlayContextId::Album(x) => PlayContextId::Album(x.clone_static()), - PlayContextId::Playlist(x) => PlayContextId::Playlist(x.clone_static()), - PlayContextId::Show(x) => PlayContextId::Show(x.clone_static()), - } - } -} - -/// Displaying the ID shows its URI -impl std::fmt::Display for PlayContextId<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self.uri()) - } -} - -#[enum_dispatch(Id)] -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum MetadataItemId<'a> { - Artist(ArtistId<'a>), - Album(AlbumId<'a>), - Track(TrackId<'a>), - Show(ShowId<'a>), - Episode(EpisodeId<'a>), -} - -impl IdBase62 for MetadataItemId<'_> {} - -// These don't work with `enum_dispatch`, unfortunately. -impl<'a> MetadataItemId<'a> { - #[must_use] - pub fn as_ref(&'a self) -> Self { - match self { - Self::Artist(x) => MetadataItemId::Artist(x.as_ref()), - Self::Album(x) => MetadataItemId::Album(x.as_ref()), - Self::Track(x) => MetadataItemId::Track(x.as_ref()), - Self::Show(x) => MetadataItemId::Show(x.as_ref()), - Self::Episode(x) => MetadataItemId::Episode(x.as_ref()), - } - } - - #[must_use] - pub fn into_static(self) -> MetadataItemId<'static> { - match self { - Self::Artist(x) => MetadataItemId::Artist(x.into_static()), - Self::Album(x) => MetadataItemId::Album(x.into_static()), - Self::Track(x) => MetadataItemId::Track(x.into_static()), - Self::Show(x) => MetadataItemId::Show(x.into_static()), - Self::Episode(x) => MetadataItemId::Episode(x.into_static()), - } - } - - #[must_use] - pub fn clone_static(&'a self) -> MetadataItemId<'static> { - match self { - Self::Artist(x) => MetadataItemId::Artist(x.clone_static()), - Self::Album(x) => MetadataItemId::Album(x.clone_static()), - Self::Track(x) => MetadataItemId::Track(x.clone_static()), - Self::Show(x) => MetadataItemId::Show(x.clone_static()), - Self::Episode(x) => MetadataItemId::Episode(x.clone_static()), - } - } -} - -/// Displaying the ID shows its URI -impl std::fmt::Display for MetadataItemId<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self.uri()) - } -} - -#[enum_dispatch(Id)] -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum PlayableId<'a> { - Track(TrackId<'a>), - Episode(EpisodeId<'a>), -} -impl IdBase62 for PlayableId<'_> {} - -// These don't work with `enum_dispatch`, unfortunately. -impl<'a> PlayableId<'a> { - #[must_use] - pub fn as_ref(&'a self) -> Self { - match self { - PlayableId::Track(x) => PlayableId::Track(x.as_ref()), - PlayableId::Episode(x) => PlayableId::Episode(x.as_ref()), - } - } - - #[must_use] - pub fn into_static(self) -> PlayableId<'static> { - match self { - PlayableId::Track(x) => PlayableId::Track(x.into_static()), - PlayableId::Episode(x) => PlayableId::Episode(x.into_static()), - } - } - - #[must_use] - pub fn clone_static(&'a self) -> PlayableId<'static> { - match self { - PlayableId::Track(x) => PlayableId::Track(x.clone_static()), - PlayableId::Episode(x) => PlayableId::Episode(x.clone_static()), - } - } - - /// Parse Spotify URI from string slice - /// - /// Spotify URI must be in one of the following formats: - /// `spotify:{type}:{id}` or `spotify/{type}/{id}`. - /// Where `{type}` is one of `artist`, `album`, `track`, - /// `playlist`, `user`, `show`, or `episode`, and `{id}` is a - /// non-empty valid string. - /// - /// Examples: `spotify:album:6IcGNaXFRf5Y1jc7QsE9O2`, - /// `spotify/track/4y4VO05kYgUTo2bzbox1an`. - /// - /// # Errors - /// - /// - `IdError::InvalidPrefix` - if `uri` is not started with - /// `spotify:` or `spotify/`, - /// - `IdError::InvalidType` - if type part of an `uri` is not a - /// valid Spotify type `T`, - /// - `IdError::InvalidId` - if id part of an `uri` is not a - /// valid id, - /// - `IdError::InvalidFormat` - if it can't be splitted into - /// type and id parts. - pub fn from_uri(uri: &'a str) -> Result { - let (tpe, id) = parse_uri(uri)?; - match tpe { - SpotifyType::Track => Ok(Self::Track(TrackId::from_id(id)?)), - SpotifyType::Episode => Ok(Self::Episode(EpisodeId::from_id(id)?)), - _ => Err(IdError::InvalidType), - } - } -} - -/// Displaying the ID shows its URI -impl std::fmt::Display for PlayableId<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self.uri()) - } -} - -impl Serialize for PlayableId<'_> { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.uri()) - } -} - -impl<'de> Deserialize<'de> for PlayableId<'static> { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct UriVisitor; - - impl serde::de::Visitor<'_> for UriVisitor { - type Value = PlayableId<'static>; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("URI for PlayableId") - } - - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - PlayableId::from_uri(v) - .map(PlayableId::into_static) - .map_err(serde::de::Error::custom) - } - } - - deserializer.deserialize_str(UriVisitor) - } -} - -#[cfg(test)] -mod test { - use super::*; - use std::{borrow::Cow, error::Error}; - - // Valid values: - const ID: &str = "4iV5W9uYEdYUVa79Axb7Rh"; - const URI: &str = "spotify:track:4iV5W9uYEdYUVa79Axb7Rh"; - const URI_SLASHES: &str = "spotify/track/4iV5W9uYEdYUVa79Axb7Rh"; - // Invalid values: - const URI_EMPTY: &str = "spotify::4iV5W9uYEdYUVa79Axb7Rh"; - const URI_WRONGTYPE1: &str = "spotify:unknown:4iV5W9uYEdYUVa79Axb7Rh"; - const URI_SHORT: &str = "track:4iV5W9uYEdYUVa79Axb7Rh"; - const URI_MIXED1: &str = "spotify/track:4iV5W9uYEdYUVa79Axb7Rh"; - const URI_MIXED2: &str = "spotify:track/4iV5W9uYEdYUVa79Axb7Rh"; - - #[test] - fn test_id_parse() { - assert!(TrackId::from_id(ID).is_ok()); - assert_eq!(TrackId::from_id(URI), Err(IdError::InvalidId)); - assert_eq!(TrackId::from_id(URI_SLASHES), Err(IdError::InvalidId)); - assert_eq!(TrackId::from_id(URI_EMPTY), Err(IdError::InvalidId)); - assert_eq!(TrackId::from_id(URI_WRONGTYPE1), Err(IdError::InvalidId)); - assert_eq!(TrackId::from_id(URI_SHORT), Err(IdError::InvalidId)); - assert_eq!(TrackId::from_id(URI_MIXED1), Err(IdError::InvalidId)); - assert_eq!(TrackId::from_id(URI_MIXED2), Err(IdError::InvalidId)); - } - - #[test] - fn test_uri_parse() { - assert!(TrackId::from_uri(URI).is_ok()); - assert!(TrackId::from_uri(URI_SLASHES).is_ok()); - assert_eq!(TrackId::from_uri(ID), Err(IdError::InvalidPrefix)); - assert_eq!(TrackId::from_uri(URI_SHORT), Err(IdError::InvalidPrefix)); - assert_eq!(TrackId::from_uri(URI_EMPTY), Err(IdError::InvalidType)); - assert_eq!(TrackId::from_uri(URI_WRONGTYPE1), Err(IdError::InvalidType)); - assert_eq!(TrackId::from_uri(URI_MIXED1), Err(IdError::InvalidFormat)); - assert_eq!(TrackId::from_uri(URI_MIXED2), Err(IdError::InvalidFormat)); - } - - /// Deserialization should accept both IDs and URIs as well. - #[test] - fn test_id_or_uri_and_deserialize() { - fn test_any(check: F) - where - F: Fn(&str) -> Result, E>, - E: Error, - { - // In this case we also check that the contents are the ID and not - // the URI. - assert!(check(ID).is_ok()); - assert_eq!(check(ID).unwrap().id(), ID); - assert!(check(URI).is_ok()); - assert_eq!(check(URI).unwrap().id(), ID); - assert!(check(URI_SLASHES).is_ok()); - assert_eq!(check(URI_SLASHES).unwrap().id(), ID); - - // These should not work in any case - assert!(check(URI_SHORT).is_err()); - assert!(check(URI_EMPTY).is_err()); - assert!(check(URI_WRONGTYPE1).is_err()); - assert!(check(URI_MIXED1).is_err()); - assert!(check(URI_MIXED2).is_err()); - } - - // Easily testing both ways to obtain an ID - test_any(|s| TrackId::from_id_or_uri(s)); - test_any(|s| { - let json = format!("\"{s}\""); - serde_json::from_str::<'_, TrackId>(&json) - }); - } - - /// Serializing should return the Id within it, not the URI. - #[test] - fn test_serialize() { - let json_expected = format!("\"{ID}\""); - let track = TrackId::from_uri(URI).unwrap(); - let json = serde_json::to_string(&track).unwrap(); - assert_eq!(json, json_expected); - } - - #[test] - fn test_multiple_types() { - fn endpoint<'a>(_ids: impl IntoIterator>) {} - - let tracks: Vec = vec![ - PlayableId::Track(TrackId::from_id(ID).unwrap()), - PlayableId::Track(TrackId::from_id(ID).unwrap()), - PlayableId::Episode(EpisodeId::from_id(ID).unwrap()), - PlayableId::Episode(EpisodeId::from_id(ID).unwrap()), - ]; - endpoint(tracks); - } - - #[test] - fn test_unknown_at_compile_time() { - fn endpoint1(input: &str, is_episode: bool) -> PlayableId<'_> { - if is_episode { - PlayableId::Episode(EpisodeId::from_id(input).unwrap()) - } else { - PlayableId::Track(TrackId::from_id(input).unwrap()) - } - } - fn endpoint2(_id: &[PlayableId]) {} - - let id = endpoint1(ID, false); - endpoint2(&[id]); - } - - #[test] - fn test_constructor() { - // With `&str` - let _ = EpisodeId::from_id(ID).unwrap(); - // With `String` - let _ = EpisodeId::from_id(ID.to_string()).unwrap(); - // With borrowed `Cow` - let _ = EpisodeId::from_id(Cow::Borrowed(ID)).unwrap(); - // With owned `Cow` - let _ = EpisodeId::from_id(Cow::Owned(ID.to_string())).unwrap(); - } - - #[test] - fn test_owned() { - // We check it twice to make sure cloning statically also works. - fn check_static(_: EpisodeId<'static>) {} - - // With lifetime smaller than static because it's a locally owned - // variable. - let local_id = String::from(ID); - - // With `&str`: should be converted - let id: EpisodeId<'_> = EpisodeId::from_id(local_id.as_str()).unwrap(); - check_static(id.clone_static()); - check_static(id.into_static()); - - // With `String`: already static - let id = EpisodeId::from_id(local_id.clone()).unwrap(); - check_static(id.clone()); - check_static(id); - } - - #[test] - fn test_user_urlencode() { - let user_name = "Hello()World"; - let id = UserId::from_id(user_name).unwrap(); - assert_eq!(id.id(), user_name); - assert_eq!(id.uri(), "spotify:user:Hello%28%29World"); - } - - struct ConversionCase { - uri: &'static str, - base16: &'static str, - base62: &'static str, - raw: &'static [u8], - } - - static CONV_VALID: [ConversionCase; 4] = [ - ConversionCase { - 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 { - 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 { - 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 { - 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, - ], - }, - ]; - - static CONV_INVALID: [ConversionCase; 5] = [ - ConversionCase { - // 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 { - // Missing colon between ID and type. - uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH", - base16: "--------------------", - base62: "....................", - raw: &[ - // Invalid length. - 154, 27, 28, 251, - ], - }, - ConversionCase { - // 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 { - uri: "spotify:azb:aRS48xBl0tH", - base16: "--------------------", - base62: "aa", - raw: &[ - // Invalid length. - 154, 27, 28, 251, - ], - }, - ConversionCase { - 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!(TrackId::from_id(c.base62).unwrap().id(), c.base62); - } - - for c in &CONV_INVALID { - assert!(TrackId::from_id(c.base62).is_err()); - } - } - - #[test] - fn from_base16() { - for c in &CONV_VALID { - assert_eq!(TrackId::from_base16(c.base16).unwrap().id(), c.base62); - } - - for c in &CONV_INVALID { - assert!(TrackId::from_base16(c.base16).is_err()); - } - } - - #[test] - fn to_base16() { - for c in &CONV_VALID { - let id = TrackId::from_id(c.base62).unwrap(); - assert_eq!(id.base16(), c.base16); - } - } - - #[test] - fn from_uri() { - for c in &CONV_VALID { - let actual = TrackId::from_uri(c.uri).unwrap(); - - assert_eq!(actual.id(), c.base62); - assert_eq!(actual.spotify_type(), SpotifyType::Track); - } - - for c in &CONV_INVALID { - assert!(TrackId::from_uri(c.uri).is_err()); - } - } - - #[test] - fn to_uri() { - for c in &CONV_VALID { - let id = TrackId::from_id(c.base62).unwrap(); - assert_eq!(id.uri(), c.uri); - } - } - - #[test] - fn from_raw() { - for c in &CONV_VALID { - assert_eq!(TrackId::from_raw(c.raw).unwrap().id(), c.base62); - } - - for c in &CONV_INVALID { - assert!(TrackId::from_raw(c.raw).is_err()); - } - } -} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index f05ecac..daf6724 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -11,7 +11,6 @@ mod device; mod enums; mod error; mod file_id; -pub mod idtypes; mod image; mod offset; mod page; @@ -20,13 +19,14 @@ mod playlist; mod recommend; mod search; mod show; +mod spotify_id; mod track; mod user; pub use { album::*, artist::*, audio::*, category::*, context::*, device::*, enums::*, error::*, - file_id::*, idtypes::*, image::*, offset::*, page::*, playing::*, playlist::*, recommend::*, - search::*, show::*, track::*, user::*, + file_id::*, image::*, offset::*, page::*, playing::*, playlist::*, recommend::*, search::*, + show::*, spotify_id::*, track::*, user::*, }; use serde::{Deserialize, Serialize}; @@ -53,10 +53,10 @@ impl PlayableItem { /// Note that if it's a track and if it's local, it may not have an ID, in /// which case this function will return `None`. #[must_use] - pub fn id(&self) -> Option> { + pub fn id(&self) -> Option<&SpotifyId> { match self { - PlayableItem::Track(t) => t.id.as_ref().map(|t| PlayableId::Track(t.as_ref())), - PlayableItem::Episode(e) => Some(PlayableId::Episode(e.id.as_ref())), + PlayableItem::Track(t) => t.id.as_ref(), + PlayableItem::Episode(e) => Some(&e.id), } } } diff --git a/crates/model/src/playlist.rs b/crates/model/src/playlist.rs index 8ddb73e..0a6d4e4 100644 --- a/crates/model/src/playlist.rs +++ b/crates/model/src/playlist.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use time::OffsetDateTime; -use crate::{Followers, Image, Page, PlayableItem, PlaylistId, PublicUser}; +use crate::{Followers, Image, Page, PlayableItem, PublicUser, SpotifyId}; /// Playlist result object #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -31,7 +31,8 @@ where pub struct SimplifiedPlaylist { pub collaborative: bool, pub href: String, - pub id: PlaylistId<'static>, + #[serde(with = "crate::spotify_id::ser::playlist")] + pub id: SpotifyId, #[serde(deserialize_with = "deserialize_null_default")] pub images: Vec, pub name: String, @@ -48,7 +49,8 @@ pub struct FullPlaylist { pub description: Option, pub followers: Followers, pub href: String, - pub id: PlaylistId<'static>, + #[serde(with = "crate::spotify_id::ser::playlist")] + pub id: SpotifyId, pub images: Vec, pub name: String, pub owner: PublicUser, diff --git a/crates/model/src/show.rs b/crates/model/src/show.rs index cb54ed8..bc3ac81 100644 --- a/crates/model/src/show.rs +++ b/crates/model/src/show.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::{CopyrightType, DatePrecision, EpisodeId, Image, Page, ShowId}; +use crate::{CopyrightType, DatePrecision, Image, Page, SpotifyId}; /// Copyright object #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -18,7 +18,8 @@ pub struct SimplifiedShow { pub description: String, pub explicit: bool, pub href: String, - pub id: ShowId<'static>, + #[serde(with = "crate::spotify_id::ser::show")] + pub id: SpotifyId, pub images: Vec, pub is_externally_hosted: Option, pub languages: Vec, @@ -49,7 +50,8 @@ pub struct FullShow { pub explicit: bool, pub episodes: Page, pub href: String, - pub id: ShowId<'static>, + #[serde(with = "crate::spotify_id::ser::show")] + pub id: SpotifyId, pub images: Vec, pub is_externally_hosted: Option, pub languages: Vec, @@ -66,7 +68,8 @@ pub struct SimplifiedEpisode { pub duration_ms: u32, pub explicit: bool, pub href: String, - pub id: EpisodeId<'static>, + #[serde(with = "crate::spotify_id::ser::episode")] + pub id: SpotifyId, pub images: Vec, pub is_externally_hosted: bool, pub is_playable: bool, @@ -89,7 +92,8 @@ pub struct FullEpisode { pub duration_ms: u32, pub explicit: bool, pub href: String, - pub id: EpisodeId<'static>, + #[serde(with = "crate::spotify_id::ser::episode")] + pub id: SpotifyId, pub images: Vec, pub is_externally_hosted: bool, pub is_playable: bool, diff --git a/crates/model/src/spotify_id.rs b/crates/model/src/spotify_id.rs index 0f23078..ca108e6 100644 --- a/crates/model/src/spotify_id.rs +++ b/crates/model/src/spotify_id.rs @@ -91,6 +91,12 @@ impl std::fmt::Display for SpotifyItemType { } } +impl SpotifyItemType { + fn uses_textual_id(self) -> bool { + matches!(self, Self::User | Self::Local) + } +} + #[derive(Clone, PartialEq, Eq, Hash)] pub struct SpotifyId { id: SpotifyIdInner, @@ -147,7 +153,7 @@ impl SpotifyId { /// /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids pub fn from_base62(src: &str) -> Result { - if src.len() != Self::SIZE_BASE62 { + if src.len() != 22 { return Err(IdError::InvalidId); } let mut dst: u128 = 0; @@ -170,29 +176,6 @@ impl SpotifyId { }) } - fn validate_textual_id(src: &str) -> Result<(), IdError> { - // forbidden chars: : /&@'" - if src.contains(['/', '&', '@', '\'', '"']) { - return Err(IdError::InvalidId); - } - Ok(()) - } - - /// Parse a textual Spotify ID (used for user IDs, which use the base62 format - /// for new accounts and the username for legacy accounts). - pub fn from_textual(src: &str) -> Result { - if src.is_empty() { - return Err(IdError::InvalidId); - } - Self::from_base62(src).or_else(|_| { - Self::validate_textual_id(src)?; - Ok(Self { - id: SpotifyIdInner::Textual(src.to_owned()), - 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`. @@ -229,39 +212,29 @@ impl SpotifyId { let (tpe, id) = rest.split_once(sep).ok_or(IdError::InvalidFormat)?; let item_type = SpotifyItemType::from(tpe); - if id.is_empty() { - return Err(IdError::InvalidId); - } - - match item_type { - SpotifyItemType::User => { - if let Ok(id) = Self::from_base62(id) { - return Ok(Self { item_type, ..id }); - } - let decoded = urlencoding::decode(id) - .map_err(|_| IdError::InvalidId)? - .into_owned(); - Self::validate_textual_id(&decoded)?; - Ok(Self { - id: SpotifyIdInner::Textual(decoded), - item_type, - }) + if item_type.uses_textual_id() { + if id.is_empty() { + return Err(IdError::InvalidId); } - SpotifyItemType::Local => Ok(Self { + Ok(Self { id: SpotifyIdInner::Textual(id.to_owned()), item_type, - }), - _ => Ok(Self { + }) + } else { + if id.len() != Self::SIZE_BASE62 { + return Err(IdError::InvalidId); + } + Ok(Self { item_type, ..Self::from_base62(id)? - }), + }) } } pub fn from_id_or_uri(id_or_uri: &str) -> Result { match Self::from_uri(id_or_uri) { Ok(id) => Ok(id), - Err(IdError::InvalidPrefix) => Self::from_textual(id_or_uri), + Err(IdError::InvalidPrefix) => Self::from_base62(id_or_uri), Err(error) => Err(error), } } @@ -277,11 +250,8 @@ impl SpotifyId { /// 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) -> Cow<'_, str> { - self._to_base62(false) - } - - fn _to_base62(&self, uri: bool) -> Cow<'_, str> { match &self.id { SpotifyIdInner::Numeric(n) => { let mut dst = [0u8; 22]; @@ -322,13 +292,7 @@ impl SpotifyId { String::from_utf8(dst.to_vec()).unwrap().into() } - SpotifyIdInner::Textual(id) => { - if uri { - urlencoding::encode(id) - } else { - id.into() - } - } + SpotifyIdInner::Textual(id) => id.into(), } } @@ -359,7 +323,7 @@ impl SpotifyId { dst.push_str("spotify:"); dst.push_str(item_type); dst.push(':'); - dst.push_str(&self._to_base62(true)); + dst.push_str(&self.to_base62()); dst } @@ -371,17 +335,10 @@ impl SpotifyId { dst.push_str("spotify:"); dst.push_str(item_type); dst.push(':'); - dst.push_str(&self._to_base62(true)); + dst.push_str(&self.to_base62()); Ok(dst) } - pub fn is_textual(&self) -> bool { - match self.id { - SpotifyIdInner::Numeric(_) => false, - SpotifyIdInner::Textual(_) => true, - } - } - pub fn into_type(self, typ: SpotifyItemType) -> Self { Self { id: self.id, @@ -426,7 +383,7 @@ impl fmt::Debug for SpotifyId { impl fmt::Display for SpotifyId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.to_base62()) + f.write_str(&self.to_uri()) } } @@ -446,7 +403,7 @@ impl<'de> Deserialize<'de> for SpotifyId { { struct SpotifyIdVisitor; - impl Visitor<'_> for SpotifyIdVisitor { + impl<'de> Visitor<'de> for SpotifyIdVisitor { type Value = SpotifyId; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { @@ -472,7 +429,7 @@ pub mod ser { struct SpecificIdVisitor; - impl Visitor<'_> for SpecificIdVisitor { + impl<'de> Visitor<'de> for SpecificIdVisitor { type Value = SpotifyId; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { @@ -483,13 +440,13 @@ pub mod ser { where E: serde::de::Error, { - SpotifyId::from_textual(v).map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`"))) + SpotifyId::from_base62(v).map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`"))) } } struct OptionalSpecificIdVisitor; - impl Visitor<'_> for OptionalSpecificIdVisitor { + impl<'de> Visitor<'de> for OptionalSpecificIdVisitor { type Value = Option; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { @@ -514,7 +471,7 @@ pub mod ser { where E: serde::de::Error, { - SpotifyId::from_textual(v) + SpotifyId::from_base62(v) .map(Some) .map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`"))) } diff --git a/crates/model/src/track.rs b/crates/model/src/track.rs index 1209499..32de0aa 100644 --- a/crates/model/src/track.rs +++ b/crates/model/src/track.rs @@ -5,7 +5,7 @@ use time::OffsetDateTime; use std::collections::HashMap; -use crate::{PlayableId, Restriction, SimplifiedAlbum, SimplifiedArtist, SpotifyType, TrackId}; +use crate::{Restriction, SimplifiedAlbum, SimplifiedArtist, SpotifyId, Type}; /// Full track object #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -20,7 +20,8 @@ pub struct FullTrack { pub external_ids: HashMap, pub href: Option, /// Note that a track may not have an ID/URI if it's local - pub id: Option>, + #[serde(with = "crate::spotify_id::ser::track::option")] + pub id: Option, pub is_local: bool, #[serde(skip_serializing_if = "Option::is_none")] pub is_playable: Option, @@ -39,8 +40,9 @@ pub struct FullTrack { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct TrackLink { pub href: String, - pub id: Option>, - pub r#type: SpotifyType, + #[serde(with = "crate::spotify_id::ser::track::option")] + pub id: Option, + pub r#type: Type, pub uri: String, } @@ -65,7 +67,8 @@ pub struct SimplifiedTrack { pub explicit: bool, #[serde(default)] pub href: Option, - pub id: Option>, + #[serde(with = "crate::spotify_id::ser::track::option")] + pub id: Option, pub is_local: bool, pub is_playable: Option, pub linked_from: Option, @@ -89,6 +92,6 @@ pub struct SavedTrack { /// `PlayableId<'a>` instead of `PlayableId<'static>` to avoid the unnecessary /// allocation. Same goes for the positions slice instead of vector. pub struct ItemPositions<'a> { - pub id: PlayableId<'static>, + pub id: SpotifyId, pub positions: &'a [u32], } diff --git a/crates/model/src/user.rs b/crates/model/src/user.rs index c09e1a4..4529fd9 100644 --- a/crates/model/src/user.rs +++ b/crates/model/src/user.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -use crate::{Country, Followers, Image, SubscriptionLevel, UserId}; +use crate::{Country, Followers, Image, SpotifyId, SubscriptionLevel}; /// Public user object #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -10,7 +10,8 @@ pub struct PublicUser { pub display_name: Option, pub followers: Option, pub href: String, - pub id: UserId<'static>, + #[serde(with = "crate::spotify_id::ser::user")] + pub id: SpotifyId, #[serde(default = "Vec::new")] pub images: Vec, } @@ -24,7 +25,8 @@ pub struct PrivateUser { pub explicit_content: Option, pub followers: Option, pub href: String, - pub id: UserId<'static>, + #[serde(with = "crate::spotify_id::ser::user")] + pub id: SpotifyId, pub images: Option>, pub product: Option, } diff --git a/crates/spotifyio/Cargo.toml b/crates/spotifyio/Cargo.toml index 447125b..4f499d7 100644 --- a/crates/spotifyio/Cargo.toml +++ b/crates/spotifyio/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spotifyio" -version = "0.0.2" +version = "0.0.1" description = "Internal Spotify API Client" edition.workspace = true authors.workspace = true @@ -69,7 +69,7 @@ pin-project-lite = "0.2" quick-xml = "0.37" urlencoding = "2.1.0" parking_lot = "0.12.0" -governor = { version = "0.8", default-features = false, features = [ +governor = { version = "0.7", default-features = false, features = [ "std", "quanta", "jitter", @@ -83,7 +83,6 @@ spotifyio-model.workspace = true [dev-dependencies] tracing-test = "0.2.5" hex_lit = "0.1" -protobuf-json-mapping = "3" [package.metadata.docs.rs] # To build locally: diff --git a/crates/spotifyio/src/audio_key.rs b/crates/spotifyio/src/audio_key.rs index 486bf86..7263919 100644 --- a/crates/spotifyio/src/audio_key.rs +++ b/crates/spotifyio/src/audio_key.rs @@ -2,12 +2,11 @@ use std::{collections::HashMap, io::Write, time::Duration}; use byteorder::{BigEndian, ByteOrder, WriteBytesExt}; use bytes::Bytes; -use spotifyio_model::{IdBase62, PlayableId}; use thiserror::Error; use tokio::sync::oneshot; use tracing::{error, trace}; -use crate::{connection::PacketType, model::FileId, util::SeqGenerator, Error}; +use crate::{connection::PacketType, util::SeqGenerator, Error, FileId, SpotifyId}; #[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] pub struct AudioKey(pub [u8; 16]); @@ -86,7 +85,7 @@ impl AudioKeyManager { } #[tracing::instrument("audio_key", level = "error", skip(self), fields(usr = self.session().user_id()))] - pub async fn request(&self, track: PlayableId<'_>, file: &FileId) -> Result { + pub async fn request(&self, track: &SpotifyId, file: &FileId) -> Result { // Make sure a connection is established self.session().connect_stored_creds().await?; @@ -109,15 +108,10 @@ impl AudioKeyManager { } } - fn send_key_request( - &self, - seq: u32, - track: PlayableId<'_>, - file: &FileId, - ) -> Result<(), Error> { + fn send_key_request(&self, seq: u32, track: &SpotifyId, file: &FileId) -> Result<(), Error> { let mut data: Vec = Vec::new(); data.write_all(&file.0)?; - data.write_all(&track.raw())?; + data.write_all(&track.to_raw()?)?; data.write_u32::(seq)?; data.write_u16::(0x0000)?; diff --git a/crates/spotifyio/src/cdn_url.rs b/crates/spotifyio/src/cdn_url.rs index f115e08..49cd0ef 100644 --- a/crates/spotifyio/src/cdn_url.rs +++ b/crates/spotifyio/src/cdn_url.rs @@ -5,7 +5,7 @@ use time::{Duration, OffsetDateTime}; use tracing::{trace, warn}; use url::Url; -use crate::{model::FileId, Error, Session}; +use super::{Error, FileId, Session}; use protocol::storage_resolve::storage_resolve_response::Result as StorageResolveResponse_Result; use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage; @@ -77,7 +77,7 @@ impl CdnUrl { Ok(cdn_url) } - pub fn try_get_url(&self) -> Result<&MaybeExpiringUrl, Error> { + pub fn try_get_url(&self) -> Result<&str, Error> { if self.urls.is_empty() { return Err(CdnUrlError::Unresolved.into()); } @@ -89,7 +89,7 @@ impl CdnUrl { }); if let Some(url) = url { - Ok(url) + Ok(&url.0) } else { Err(CdnUrlError::Expired.into()) } diff --git a/crates/spotifyio/src/connection/handshake.rs b/crates/spotifyio/src/connection/handshake.rs index 04a12b8..259166d 100644 --- a/crates/spotifyio/src/connection/handshake.rs +++ b/crates/spotifyio/src/connection/handshake.rs @@ -226,11 +226,11 @@ where Ok(message) } -async fn read_into_accumulator<'a, T: AsyncRead + Unpin>( - connection: &mut T, +async fn read_into_accumulator<'a, 'b, T: AsyncRead + Unpin>( + connection: &'a mut T, size: usize, - acc: &'a mut Vec, -) -> io::Result<&'a mut [u8]> { + acc: &'b mut Vec, +) -> io::Result<&'b mut [u8]> { let offset = acc.len(); acc.resize(offset + size, 0); diff --git a/crates/spotifyio/src/error.rs b/crates/spotifyio/src/error.rs index a343dc5..bfa9f97 100644 --- a/crates/spotifyio/src/error.rs +++ b/crates/spotifyio/src/error.rs @@ -70,8 +70,8 @@ pub enum ErrorKind { #[error("Service unavailable")] Unavailable = 14, - #[error("Entity has not been modified since the last request")] - NotModified = 15, + #[error("Unrecoverable data loss or corruption")] + DataLoss = 15, #[error("Operation must not be used")] DoNotUse = -1, @@ -127,12 +127,12 @@ impl Error { } } - pub fn not_modified(error: E) -> Error + pub fn data_loss(error: E) -> Error where E: Into>, { Self { - kind: ErrorKind::NotModified, + kind: ErrorKind::DataLoss, error: error.into(), } } @@ -297,7 +297,6 @@ impl From for Error { StatusCode::UNAUTHORIZED => Self::unauthenticated(err), StatusCode::FORBIDDEN => Self::permission_denied(err), StatusCode::TOO_MANY_REQUESTS => Self::resource_exhausted(err), - StatusCode::GATEWAY_TIMEOUT => Self::deadline_exceeded(err), _ => Self::unknown(err), } } else if err.is_body() || err.is_request() { @@ -452,22 +451,3 @@ impl From for Error { Self::invalid_argument(value) } } - -pub trait NotModifiedRes { - fn into_option(self) -> Result, Error>; -} - -impl NotModifiedRes for Result { - fn into_option(self) -> Result, Error> { - match self { - Ok(res) => Ok(Some(res)), - Err(e) => { - if matches!(e.kind, ErrorKind::NotModified) { - Ok(None) - } else { - Err(e) - } - } - } - } -} diff --git a/crates/spotifyio/src/lib.rs b/crates/spotifyio/src/lib.rs index f573bcb..c93fdd6 100644 --- a/crates/spotifyio/src/lib.rs +++ b/crates/spotifyio/src/lib.rs @@ -1,6 +1,6 @@ #![doc = include_str!("../README.md")] #![cfg_attr(docsrs, feature(doc_cfg))] -#![warn(clippy::todo)] +#![warn(clippy::todo, clippy::dbg_macro)] #[macro_use] mod component; @@ -18,7 +18,7 @@ mod pool; mod session; mod spclient; -pub mod gql_model; +pub mod model; pub mod util; #[cfg(feature = "oauth")] @@ -36,17 +36,19 @@ use spclient::SpClient; pub use audio_key::{AudioKey, AudioKeyError}; pub use authentication::AuthCredentials; pub use cache::{ApplicationCache, SessionCache}; -pub use cdn_url::{CdnUrl, CdnUrlError, MaybeExpiringUrl}; -pub use error::{Error, ErrorKind, NotModifiedRes}; +pub use cdn_url::{CdnUrl, CdnUrlError}; +pub use error::{Error, ErrorKind}; pub use governor::{Jitter, Quota}; pub use normalisation::NormalisationData; pub use pool::PoolError; pub use session::{Session, SessionConfig}; -pub use spclient::{EtagResponse, RequestStrategy}; +pub use spclient::RequestStrategy; +pub use spotifyio_model::{ + AlbumType, FileId, IdError, IncludeExternal, Market, RecommendationsAttribute, SearchResult, + SearchType, SpotifyId, SpotifyItemType, +}; pub use spotifyio_protocol::authentication::AuthenticationType; -pub use spotifyio_model as model; - /// Protobuf enums pub mod pb { pub use spotifyio_protocol::canvaz_meta::Type as CanvazType; @@ -67,7 +69,6 @@ pub mod pb { ListAttributeKind, }; pub use spotifyio_protocol::playlist_permission::PermissionLevel; - pub use spotifyio_protocol::*; } #[derive(Clone)] @@ -181,10 +182,6 @@ impl SpotifyIoPool { Ok(self.inner.pool.get().await?.audio_key()) } - pub async fn audio_key_session(&self) -> Result<&Session, PoolError> { - self.inner.pool.get().await - } - pub async fn audio_key_timeout( &self, timeout: Duration, @@ -192,13 +189,6 @@ impl SpotifyIoPool { Ok(self.inner.pool.get_timeout(timeout).await?.audio_key()) } - pub async fn audio_key_session_timeout( - &self, - timeout: Duration, - ) -> Result<&Session, PoolError> { - self.inner.pool.get_timeout(timeout).await - } - pub fn shutdown(&self) { for session in self.inner.pool.iter() { session.shutdown(); @@ -206,7 +196,6 @@ impl SpotifyIoPool { } #[cfg(test)] - #[allow(unused)] pub(crate) fn testing() -> Self { use path_macro::path; diff --git a/crates/spotifyio/src/gql_model.rs b/crates/spotifyio/src/model.rs similarity index 85% rename from crates/spotifyio/src/gql_model.rs rename to crates/spotifyio/src/model.rs index 35292e9..da7c581 100644 --- a/crates/spotifyio/src/gql_model.rs +++ b/crates/spotifyio/src/model.rs @@ -6,9 +6,7 @@ use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DefaultOnError, DisplayFromStr}; use time::OffsetDateTime; -use spotifyio_model::{ - AlbumId, ArtistId, ConcertId, PlaylistId, PrereleaseId, SongwriterId, TrackId, UserId, -}; +use crate::SpotifyId; #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct LyricsWrap { @@ -31,7 +29,7 @@ pub struct GqlWrap { #[serde(tag = "__typename")] #[allow(clippy::large_enum_variant)] pub enum PlaylistOption { - Playlist(GqlPlaylistItem), + Playlist(PlaylistItem), #[serde(alias = "GenericError")] NotFound, } @@ -102,7 +100,7 @@ pub(crate) struct ArtistGqlWrap { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ArtistGql { - pub uri: ArtistId<'static>, + pub uri: SpotifyId, pub profile: ArtistProfile, pub related_content: Option, pub stats: Option, @@ -122,10 +120,7 @@ pub struct ArtistProfile { pub verified: bool, #[serde(default)] pub biography: Biography, - #[serde(default)] - pub external_links: GqlPagination, - #[serde(default)] - pub playlists_v2: GqlPagination>, + pub playlists_v2: Option>>, } #[derive(Default, Debug, Clone, Serialize, Deserialize)] @@ -134,13 +129,6 @@ pub struct Biography { pub text: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub struct ExternalLink { - pub name: String, - pub url: String, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[non_exhaustive] @@ -189,7 +177,7 @@ pub struct Events { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct Concert { - pub uri: ConcertId<'static>, + pub uri: SpotifyId, pub title: String, pub date: DateWrap, #[serde(default)] @@ -270,8 +258,8 @@ pub struct Name { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[non_exhaustive] -pub struct GqlPlaylistItem { - pub uri: PlaylistId<'static>, +pub struct PlaylistItem { + pub uri: SpotifyId, pub name: String, pub images: GqlPagination, pub owner_v2: GqlWrap, @@ -281,9 +269,9 @@ pub struct GqlPlaylistItem { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct UserItem { - pub uri: Option>, + pub uri: Option, #[serde(alias = "displayName")] - pub name: Option, + pub name: String, pub avatar: Option, } @@ -291,7 +279,7 @@ pub struct UserItem { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ArtistItem { - pub uri: ArtistId<'static>, + pub uri: SpotifyId, pub profile: Name, pub visuals: Option, } @@ -318,7 +306,7 @@ pub(crate) struct PrereleaseLookup { #[non_exhaustive] pub struct PrereleaseItem { /// URI of the prerelease - pub uri: PrereleaseId<'static>, + pub uri: SpotifyId, pub pre_release_content: PrereleaseContent, pub release_date: DateWrap, } @@ -328,9 +316,9 @@ pub struct PrereleaseItem { #[non_exhaustive] pub struct PrereleaseContent { /// URI of the to-be-released album - pub uri: Option>, + pub uri: Option, pub name: String, - pub cover_art: Option, + pub cover_art: Image, pub artists: Option>>, pub tracks: Option>, pub copyright: Option, @@ -340,7 +328,7 @@ pub struct PrereleaseContent { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct PrereleaseTrackItem { - pub uri: TrackId<'static>, + pub uri: SpotifyId, pub name: String, pub duration: Option, pub artists: GqlPagination>, @@ -407,10 +395,10 @@ pub enum AlbumItemWrap { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct AlbumItem { - pub uri: AlbumId<'static>, + pub uri: SpotifyId, pub name: String, pub date: Option, - pub cover_art: Option, + pub cover_art: Image, pub artists: Option>, } @@ -418,7 +406,7 @@ pub struct AlbumItem { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct TrackItem { - pub uri: TrackId<'static>, + pub uri: SpotifyId, pub name: String, pub duration: DurationWrap, pub artists: GqlPagination, @@ -435,7 +423,7 @@ pub(crate) struct ConcertGqlWrap { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ConcertGql { - pub uri: ConcertId<'static>, + pub uri: SpotifyId, pub title: String, #[serde(default)] pub artists: GqlPagination>, @@ -481,7 +469,6 @@ pub struct ConcertOfferDates { pub end_date_iso_string: OffsetDateTime, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SearchItemType { Artists, Albums, @@ -493,7 +480,7 @@ pub enum SearchItemType { #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub struct UserProfile { - pub uri: UserId<'static>, + pub uri: SpotifyId, pub name: Option, pub image_url: Option, pub followers_count: Option, @@ -503,26 +490,18 @@ pub struct UserProfile { pub total_public_playlists_count: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub struct UserPlaylists { - #[serde(default)] - pub public_playlists: Vec, - pub total_public_playlists_count: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub struct PublicPlaylistItem { - pub uri: PlaylistId<'static>, + pub uri: SpotifyId, pub name: String, pub owner_name: String, - pub owner_uri: UserId<'static>, + pub owner_uri: String, /// UID-based image id /// /// - `spotify:image:ab67706c0000da8474fffd106bb7f5be3ba4b758` /// - `spotify:mosaic:ab67616d00001e021c04efd2804b16cf689de7f0:ab67616d00001e0269f63a842ea91ca7c522593a:ab67616d00001e0270dbc9f47669d120ad874ec1:ab67616d00001e027d384516b23347e92a587ed1` - pub image_url: Option, + pub image_url: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -534,7 +513,7 @@ pub(crate) struct UserProfilesWrap { #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub struct FollowerItem { - pub uri: UserId<'static>, + pub uri: SpotifyId, pub name: Option, pub followers_count: Option, pub image_url: Option, @@ -547,7 +526,6 @@ pub struct Seektable { pub encoder_delay_samples: u32, pub pssh: String, pub timescale: u32, - #[serde(alias = "init_range")] pub index_range: (u32, u32), pub segments: Vec<(u32, u32)>, pub offset: usize, @@ -557,7 +535,7 @@ pub struct Seektable { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TrackCredits { - pub track_uri: TrackId<'static>, + pub track_uri: SpotifyId, pub track_title: String, #[serde(default)] pub role_credits: Vec, @@ -577,47 +555,11 @@ pub struct RoleCredits { #[serde(rename_all = "camelCase")] pub struct CreditedArtist { pub name: String, - pub uri: Option>, - pub creator_uri: Option>, + #[serde(alias = "creatorUri")] + pub uri: Option, pub external_url: Option, /// Image URL pub image_uri: Option, pub subroles: Vec, pub weight: f32, } - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlaylistWrap { - pub playlist_v2: PlaylistOption, -} - -impl PlaylistOption { - pub fn into_option(self) -> Option { - match self { - PlaylistOption::Playlist(playlist_item) => Some(playlist_item), - PlaylistOption::NotFound => None, - } - } -} - -impl ConcertOption { - pub fn into_option(self) -> Option { - match self { - ConcertOption::ConcertV2(concert) => Some(concert), - ConcertOption::NotFound => None, - } - } -} - -impl From> for Option { - fn from(value: GqlWrap) -> Self { - value.data.into_option() - } -} - -impl From> for Option { - fn from(value: GqlWrap) -> Self { - value.data.into_option() - } -} diff --git a/crates/spotifyio/src/normalisation.rs b/crates/spotifyio/src/normalisation.rs index f65fbb3..535489d 100644 --- a/crates/spotifyio/src/normalisation.rs +++ b/crates/spotifyio/src/normalisation.rs @@ -25,7 +25,7 @@ impl NormalisationData { let newpos = file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?; if newpos != SPOTIFY_NORMALIZATION_HEADER_START_OFFSET { return Err(Error::failed_precondition(format!( - "NormalisationData::parse_from_ogg seeking to {} but position is now {}", + "NormalisationData::parse_from_file seeking to {} but position is now {}", SPOTIFY_NORMALIZATION_HEADER_START_OFFSET, newpos ))); } diff --git a/crates/spotifyio/src/oauth.rs b/crates/spotifyio/src/oauth.rs index 81e8dd6..f8b491a 100644 --- a/crates/spotifyio/src/oauth.rs +++ b/crates/spotifyio/src/oauth.rs @@ -14,7 +14,7 @@ use oauth2::{ basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenResponse, TokenUrl, }; -use spotifyio_model::{Id, PrivateUser}; +use spotifyio_model::PrivateUser; use std::io; use std::time::{Duration, Instant}; use std::{ @@ -280,7 +280,7 @@ pub async fn new_session( access_token: &str, ) -> Result { let profile = get_own_profile(access_token).await?; - let sc = app_cache.new_session(profile.id.id().to_owned()); + let sc = app_cache.new_session(profile.id.to_base62().into_owned()); sc.write_email(profile.email); if let Some(country) = profile.country { let cstr: &'static str = country.into(); diff --git a/crates/spotifyio/src/session.rs b/crates/spotifyio/src/session.rs index 04589e7..cdc8385 100644 --- a/crates/spotifyio/src/session.rs +++ b/crates/spotifyio/src/session.rs @@ -34,9 +34,9 @@ use crate::{ cache::SessionCache, connection::{self, AuthenticationError, PacketType, Transport}, error::Error, - model::FileId, spclient::{RequestStrategy, SpClient}, util::{self, SocketAddress}, + FileId, }; #[derive(Debug, thiserror::Error)] @@ -387,12 +387,18 @@ impl Session { } } - pub fn image_url(&self, image_id: &FileId) -> String { - format!("https://i.scdn.co/image/{}", &image_id.base16()) + pub fn image_url(&self, image_id: &FileId) -> Result { + Ok(format!( + "https://i.scdn.co/image/{}", + &image_id.to_base16()? + )) } - pub fn audio_preview_url(&self, preview_id: &FileId) -> String { - format!("https://p.scdn.co/mp3-preview/{}", &preview_id.base16()) + pub fn audio_preview_url(&self, preview_id: &FileId) -> Result { + Ok(format!( + "https://p.scdn.co/mp3-preview/{}", + &preview_id.to_base16()? + )) } pub fn shutdown(&self) { @@ -732,10 +738,11 @@ where #[cfg(test)] mod tests { + use crate::{FileId, SpotifyId}; + use super::*; use data_encoding::{BASE64, HEXLOWER}; use hex_lit::hex; - use spotifyio_model::{IdConstruct, PlayableId, TrackId}; use tracing_test::traced_test; #[tokio::test] @@ -803,9 +810,9 @@ mod tests { async fn connection_key() { let s = Session::new(SessionConfig::default(), SessionCache::testing()); - let spotify_id = PlayableId::Track(TrackId::from_id("4AY7RuEn7nmnxtvya5ygCt").unwrap()); + let spotify_id = SpotifyId::from_base62("4AY7RuEn7nmnxtvya5ygCt").unwrap(); let file_id = FileId::from_base16("560ad6f0d543aa20e7851f891b4e40ba47d54b54").unwrap(); - let key = s.audio_key().request(spotify_id, &file_id).await.unwrap(); + let key = s.audio_key().request(&spotify_id, &file_id).await.unwrap(); assert_eq!(key.0, hex!("0ec8107d77338dcb4d31d5feb4e220fb")); } diff --git a/crates/spotifyio/src/spclient.rs b/crates/spotifyio/src/spclient.rs index 74dd472..e7c2990 100644 --- a/crates/spotifyio/src/spclient.rs +++ b/crates/spotifyio/src/spclient.rs @@ -5,11 +5,19 @@ use bytes::Bytes; use data_encoding::{HEXLOWER_PERMISSIVE, HEXUPPER_PERMISSIVE}; use parking_lot::Mutex; use protobuf::{Enum, EnumOrUnknown, Message}; -use reqwest::{header, Method, Response, StatusCode}; +use reqwest::{header, Method, Response}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha1::{Digest, Sha1}; +use spotifyio_model::{ + AlbumType, AudioAnalysis, AudioFeatures, AudioFeaturesPayload, Category, CategoryPlaylists, + EpisodesPayload, FeaturedPlaylists, FullAlbum, FullAlbums, FullArtist, FullArtists, + FullEpisode, FullPlaylist, FullShow, FullTrack, FullTracks, IncludeExternal, Market, Page, + PageCategory, PageSimplifiedAlbums, PlaylistItem, PrivateUser, PublicUser, Recommendations, + RecommendationsAttribute, SearchMultipleResult, SearchResult, SearchType, SimplifiedAlbum, + SimplifiedEpisode, SimplifiedPlaylist, SimplifiedShow, SimplifiedShows, SimplifiedTrack, +}; use time::{Duration, OffsetDateTime}; -use tracing::{debug, error, info, trace, warn}; +use tracing::{debug, info, trace, warn}; use spotifyio_protocol::{ canvaz::{entity_canvaz_response::Canvaz, EntityCanvazRequest, EntityCanvazResponse}, @@ -31,26 +39,15 @@ use spotifyio_protocol::{ use crate::{ error::ErrorKind, - gql_model::{ - ArtistGql, ArtistGqlWrap, Concert, ConcertGql, ConcertGqlWrap, ConcertOption, FollowerItem, - GqlPlaylistItem, GqlSearchResult, GqlWrap, Lyrics, LyricsWrap, PlaylistWrap, - PrereleaseItem, PrereleaseLookup, SearchItemType, SearchResultWrap, Seektable, - TrackCredits, UserPlaylists, UserProfile, UserProfilesWrap, - }, model::{ - AlbumId, AlbumType, ArtistId, AudioAnalysis, AudioFeatures, AudioFeaturesPayload, Category, - CategoryPlaylists, ConcertId, EpisodeId, EpisodesPayload, FeaturedPlaylists, FileId, - FullAlbum, FullAlbums, FullArtist, FullArtists, FullEpisode, FullPlaylist, FullShow, - FullTrack, FullTracks, Id, IdBase62, IdConstruct, IncludeExternal, Market, MetadataItemId, - Page, PageCategory, PageSimplifiedAlbums, PlaylistId, PlaylistItem, PrereleaseId, - PrivateUser, PublicUser, Recommendations, RecommendationsAttribute, SearchMultipleResult, - SearchResult, SearchType, ShowId, SimplifiedAlbum, SimplifiedEpisode, SimplifiedPlaylist, - SimplifiedShow, SimplifiedShows, SimplifiedTrack, SpotifyType, TrackId, UserId, + ArtistGql, ArtistGqlWrap, Concert, ConcertGql, ConcertGqlWrap, ConcertOption, FollowerItem, + GqlSearchResult, GqlWrap, Lyrics, LyricsWrap, PrereleaseItem, PrereleaseLookup, + SearchItemType, SearchResultWrap, Seektable, TrackCredits, UserProfile, UserProfilesWrap, }, pagination::{paginate, paginate_with_ctx, Paginator}, session::SessionWeak, util::{self, SocketAddress}, - Error, Session, + Error, FileId, Session, SpotifyId, SpotifyItemType, }; #[derive(Deserialize)] @@ -116,23 +113,6 @@ impl Default for RequestStrategy { } } -#[derive(Default)] -struct RequestParams<'a> { - method: Method, - endpoint: &'a str, - params: Option<&'a Query<'a>>, - public_api: bool, - content_type: Option, - body: Vec, - if_none_match: Option<&'a str>, -} - -#[derive(Debug)] -pub struct EtagResponse { - pub data: T, - pub etag: Option, -} - #[derive(Debug, Clone, Copy)] enum ContentType { Protobuf, @@ -500,8 +480,13 @@ impl SpClient { #[tracing::instrument("spclient", level = "error", skip_all, fields(usr = self.session().user_id()))] pub async fn _request_generic( &self, - p: RequestParams<'_>, - ) -> Result, Error> { + method: Method, + endpoint: &str, + params: Option<&Query<'_>>, + public_api: bool, + content_type: Option, + body: Vec, + ) -> Result { let mut tries: usize = 0; let mut last_error: Error; let mut retry_after: Option = None; @@ -509,19 +494,19 @@ impl SpClient { loop { tries += 1; - let url = if p.endpoint.starts_with('/') { - if p.public_api { - format!("https://api.spotify.com/v1{}", p.endpoint) + let url = if endpoint.starts_with('/') { + if public_api { + format!("https://api.spotify.com/v1{endpoint}") } else { // Reconnection logic: retrieve the endpoint every iteration, so we can try // another access point when we are experiencing network issues (see below). let mut url = self.base_url().await?; - url.push_str(p.endpoint); + url.push_str(endpoint); url } } else { - p.endpoint.to_string() + endpoint.to_string() }; // Reconnection logic: keep getting (cached) tokens because they might have expired. @@ -530,22 +515,22 @@ impl SpClient { let mut request = self .session() .http_client() - .request(p.method.clone(), url) + .request(method.clone(), url) .bearer_auth(auth_token) .header(header::ACCEPT_LANGUAGE, &self.session().config().language); - if let Some(content_type) = p.content_type { + if let Some(content_type) = content_type { request = request.header(header::ACCEPT, content_type.mime()); } - if !p.body.is_empty() { - request = request.body(p.body.clone()); - if let Some(content_type) = p.content_type { + if !body.is_empty() { + request = request.body(body.clone()); + if let Some(content_type) = content_type { request = request.header(header::CONTENT_TYPE, content_type.mime()); } } - if !p.public_api { + if !public_api { match self.client_token().await { Ok(client_token) => { request = request.header("client-token", client_token); @@ -557,11 +542,7 @@ impl SpClient { } } - if let Some(t) = p.if_none_match { - request = request.header(header::IF_NONE_MATCH, t); - } - - if let Some(params) = p.params { + if let Some(params) = params { request = request.query(params); } @@ -569,38 +550,24 @@ impl SpClient { last_error = match resp { Ok(resp) => { - if matches!(resp.status(), StatusCode::NOT_MODIFIED) { - return Err(Error::not_modified("")); - } - let estatus = resp.error_for_status_ref().err(); - let etag = resp - .headers() - .get(header::ETAG) - .and_then(|v| v.to_str().ok()) - .map(str::to_owned); retry_after = resp .headers() - .get(header::RETRY_AFTER) + .get("retry-after") .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse().ok()); - - match estatus { - Some(e) => { - // Protobuf API returns no textual error messages - if !matches!(p.content_type, Some(ContentType::Protobuf)) { - if let Ok(emsg) = resp.text().await { - debug!("error response:\n{emsg}"); - } + match resp.bytes().await { + Ok(bytes) => match estatus { + None => return Ok(bytes), + Some(e) => { + tracing::debug!( + "error response:\n{}", + String::from_utf8_lossy(&bytes) + ); + e.into() } - e.into() - } - None => match resp.bytes().await { - Ok(data) => { - return Ok(EtagResponse { data, etag }); - } - Err(e) => e.into(), }, + Err(e) => estatus.unwrap_or(e).into(), } } Err(e) => e, @@ -618,7 +585,7 @@ impl SpClient { // This will cause the next call to base_url() to resolve a new one. match last_error.kind { ErrorKind::Unavailable | ErrorKind::DeadlineExceeded => { - warn!("API error: {last_error} (retrying)"); + tracing::warn!("API error: {last_error} (retrying)"); // Keep trying the current access point three times before dropping it. if tries % 3 == 0 { self.flush_accesspoint().await @@ -627,16 +594,16 @@ impl SpClient { ErrorKind::ResourceExhausted => { let wait_time = retry_after.unwrap_or(15); if wait_time > 30 { - error!("API error 429: need to wait {wait_time}s"); + tracing::error!("API error 429: need to wait {wait_time}s"); break; } else { - warn!("API error 429: waiting for {wait_time}s"); + tracing::warn!("API error 429: waiting for {wait_time}s"); tokio::time::sleep(std::time::Duration::from_secs(wait_time)).await; } } _ => { // if we can't build the request now, then we won't ever - error!("API error: {last_error}"); + tracing::error!("API error: {last_error}"); break; } } @@ -651,48 +618,48 @@ impl SpClient { message: &impl Message, ) -> Result { let res = self - ._request_generic(RequestParams { + ._request_generic( method, endpoint, - content_type: Some(ContentType::Protobuf), - body: message.write_to_bytes()?, - ..Default::default() - }) + None, + false, + Some(ContentType::Protobuf), + message.write_to_bytes()?, + ) .await?; - Ok(O::parse_from_bytes(&res.data)?) + Ok(O::parse_from_bytes(&res)?) } - pub async fn request_get_pb( - &self, - endpoint: &str, - if_none_match: Option<&str>, - ) -> Result, Error> { + pub async fn request_get_pb(&self, endpoint: &str) -> Result { let res = self - ._request_generic(RequestParams { + ._request_generic( + Method::GET, endpoint, - content_type: Some(ContentType::Protobuf), - if_none_match, - ..Default::default() - }) + None, + false, + Some(ContentType::Protobuf), + Vec::new(), + ) .await?; - Ok(EtagResponse { - data: O::parse_from_bytes(&res.data)?, - etag: res.etag, - }) + Ok(O::parse_from_bytes(&res)?) } async fn request_get_json(&self, endpoint: &str) -> Result { let res = self - ._request_generic(RequestParams { + ._request_generic( + Method::GET, endpoint, - content_type: Some(ContentType::Json), - ..Default::default() - }) + None, + false, + Some(ContentType::Protobuf), + Vec::new(), + ) .await?; - match serde_json::from_slice(&res.data) { + // Ok(serde_json::from_slice(&res)?) + match serde_json::from_slice(&res) { Ok(res) => Ok(res), Err(e) => { - debug!("JSON response:\n{}", String::from_utf8_lossy(&res.data)); + debug!("JSON response:\n{}", String::from_utf8_lossy(&res)); Err(e.into()) } } @@ -704,116 +671,78 @@ impl SpClient { params: Option<&Query<'_>>, ) -> Result { let res = self - ._request_generic(RequestParams { + ._request_generic( + Method::GET, endpoint, params, - public_api: true, - content_type: Some(ContentType::Json), - ..Default::default() - }) + true, + Some(ContentType::Json), + Vec::new(), + ) .await?; - Ok(serde_json::from_slice(&res.data)?) + Ok(serde_json::from_slice(&res)?) } async fn get_metadata( &self, - id: MetadataItemId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - debug!("getting metadata for {id}"); - self.request_get_pb( - &format!("/metadata/4/{}/{}", id.spotify_type(), id.base16()), - if_none_match, - ) - .await - } - - pub async fn pb_artist( - &self, - id: ArtistId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - self.get_metadata(MetadataItemId::Artist(id), if_none_match) + item_type: SpotifyItemType, + id: &SpotifyId, + ) -> Result { + self.request_get_pb(&format!("/metadata/4/{}/{}", item_type, id.to_base16()?)) .await } - pub async fn pb_album( - &self, - id: AlbumId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - self.get_metadata(MetadataItemId::Album(id), if_none_match) - .await + pub async fn pb_artist(&self, id: &SpotifyId) -> Result { + self.get_metadata(SpotifyItemType::Artist, id).await } - pub async fn pb_track( - &self, - id: TrackId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - self.get_metadata(MetadataItemId::Track(id), if_none_match) - .await + pub async fn pb_album(&self, id: &SpotifyId) -> Result { + self.get_metadata(SpotifyItemType::Album, id).await } - pub async fn pb_show( - &self, - id: ShowId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - self.get_metadata(MetadataItemId::Show(id), if_none_match) - .await + pub async fn pb_track(&self, id: &SpotifyId) -> Result { + self.get_metadata(SpotifyItemType::Track, id).await } - pub async fn pb_episode( - &self, - id: EpisodeId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - self.get_metadata(MetadataItemId::Episode(id), if_none_match) - .await + pub async fn pb_show(&self, id: &SpotifyId) -> Result { + self.get_metadata(SpotifyItemType::Show, id).await } - pub async fn pb_playlist( - &self, - id: PlaylistId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - debug!("getting metadata for playlist {id}"); - self.request_get_pb(&format!("/playlist/v2/playlist/{}", id.id()), if_none_match) + pub async fn pb_episode(&self, id: &SpotifyId) -> Result { + self.get_metadata(SpotifyItemType::Episode, id).await + } + + pub async fn pb_playlist(&self, id: &SpotifyId) -> Result { + self.request_get_pb(&format!("/playlist/v2/playlist/{}", id.to_base62())) .await } pub async fn pb_playlist_diff( &self, - id: PlaylistId<'_>, + id: &SpotifyId, revision: &[u8], ) -> Result { - debug!("getting diff for playlist {id}"); let endpoint = format!( "/playlist/v2/playlist/{}/diff?revision={},{}", - id.id(), + id.to_base62(), BigEndian::read_u32(&revision[0..4]), HEXLOWER_PERMISSIVE.encode(&revision[4..]), ); - Ok(self.request_get_pb(&endpoint, None).await?.data) + + self.request_get_pb(&endpoint).await } - async fn pb_extended_metadata( + async fn pb_extended_metadata<'a, O: Message>( &self, - ids: impl IntoIterator + Send, - ) -> Result, Error> { - let mut ids = ids.into_iter().peekable(); - let item_type = if let Some(first) = ids.peek() { - first.spotify_type() - } else { - return Ok(Vec::new()); - }; + ids: impl IntoIterator + Send + 'a, + item_type: SpotifyItemType, + ) -> Result, Error> { let kind = match item_type { - SpotifyType::Album => ExtensionKind::ALBUM_V4, - SpotifyType::Artist => ExtensionKind::ARTIST_V4, - SpotifyType::Episode => ExtensionKind::EPISODE_V4, - SpotifyType::Show => ExtensionKind::SHOW_V4, - SpotifyType::Track => ExtensionKind::TRACK_V4, + SpotifyItemType::Album => ExtensionKind::ALBUM_V4, + SpotifyItemType::Artist => ExtensionKind::ARTIST_V4, + SpotifyItemType::Episode => ExtensionKind::EPISODE_V4, + SpotifyItemType::Show => ExtensionKind::SHOW_V4, + SpotifyItemType::Track => ExtensionKind::TRACK_V4, _ => return Err(Error::invalid_argument("unsupported item type")), }; @@ -822,22 +751,17 @@ impl SpClient { header.catalogue = self.session().catalogue().to_owned(); header.country = self.session().country().unwrap_or_default(); request.entity_request = ids + .into_iter() .map(|id| { let mut r = EntityRequest::new(); - r.entity_uri = id.uri(); + r.entity_uri = id.clone().into_type(item_type).to_uri(); let mut rq1 = ExtensionQuery::new(); rq1.extension_kind = EnumOrUnknown::new(kind); r.query.push(rq1); - Ok(r) + r }) - .collect::, Error>>()?; - - debug!( - "getting metadata for {} {}s", - request.entity_request.len(), - item_type - ); + .collect(); let resp = self .request_pb::( @@ -850,62 +774,61 @@ impl SpClient { resp.extended_metadata .into_iter() .flat_map(|x| { - x.extension_data.into_iter().filter_map(|itm| { - if itm.extension_data.is_some() { - Some(O::parse_from_bytes(&itm.extension_data.value).map_err(Error::from)) - } else { - None - } + x.extension_data.into_iter().map(|itm| { + O::parse_from_bytes(&itm.extension_data.value) + .map_err(Error::from) + .and_then(|parsed| Ok((SpotifyId::from_uri(&itm.entity_uri)?, parsed))) }) }) - .collect() + .collect::, _>>() } - pub async fn pb_artists( + pub async fn pb_artists<'a>( &self, - ids: impl IntoIterator> + Send, - ) -> Result, Error> { - self.pb_extended_metadata(ids).await + ids: impl IntoIterator + Send + 'a, + ) -> Result, Error> { + self.pb_extended_metadata(ids, SpotifyItemType::Artist) + .await } - pub async fn pb_albums( + pub async fn pb_albums<'a>( &self, - ids: impl IntoIterator> + Send, - ) -> Result, Error> { - self.pb_extended_metadata(ids).await + ids: impl IntoIterator + Send + 'a, + ) -> Result, Error> { + self.pb_extended_metadata(ids, SpotifyItemType::Album).await } - pub async fn pb_tracks( + pub async fn pb_tracks<'a>( &self, - ids: impl IntoIterator> + Send, - ) -> Result, Error> { - self.pb_extended_metadata(ids).await + ids: impl IntoIterator + Send + 'a, + ) -> Result, Error> { + self.pb_extended_metadata(ids, SpotifyItemType::Track).await } - pub async fn pb_shows( + pub async fn pb_shows<'a>( &self, - ids: impl IntoIterator> + Send, - ) -> Result, Error> { - self.pb_extended_metadata(ids).await + ids: impl IntoIterator + Send + 'a, + ) -> Result, Error> { + self.pb_extended_metadata(ids, SpotifyItemType::Show).await } - pub async fn pb_episodes( + pub async fn pb_episodes<'a>( &self, - ids: impl IntoIterator> + Send, - ) -> Result, Error> { - self.pb_extended_metadata(ids).await + ids: impl IntoIterator + Send + 'a, + ) -> Result, Error> { + self.pb_extended_metadata(ids, SpotifyItemType::Episode) + .await } pub async fn pb_audio_storage( &self, file_id: &FileId, ) -> Result { - debug!("getting audio storage for {file_id}"); let endpoint = format!( "/storage-resolve/files/audio/interactive/{}", - file_id.base16() + file_id.to_base16()? ); - Ok(self.request_get_pb(&endpoint, None).await?.data) + self.request_get_pb(&endpoint).await } pub async fn get_obfuscated_key( @@ -914,8 +837,6 @@ impl SpClient { token: &[u8; 16], is_episode: bool, ) -> Result<[u8; 16], Error> { - debug!("getting obfuscated key for {file_id}"); - let mut req = PlayPlayLicenseRequest::new(); req.set_version(2); req.set_token(token.to_vec()); @@ -930,7 +851,7 @@ impl SpClient { let license = self .request_pb::( Method::POST, - &format!("/playplay/v1/key/{}", file_id.base16()), + &format!("/playplay/v1/key/{}", file_id.to_base16()?), &req, ) .await?; @@ -941,8 +862,7 @@ impl SpClient { } pub async fn get_image(&self, image_id: &FileId) -> Result { - debug!("getting image {image_id}"); - let url = self.session().image_url(image_id); + let url = self.session().image_url(image_id)?; let resp = self .session() @@ -955,8 +875,7 @@ impl SpClient { } pub async fn get_audio_preview(&self, preview_id: &FileId) -> Result { - debug!("getting audio preview {preview_id}"); - let mut url = self.session().audio_preview_url(preview_id); + let mut url = self.session().audio_preview_url(preview_id)?; let separator = match url.find('?') { Some(_) => "&", None => "?", @@ -975,10 +894,9 @@ impl SpClient { /// Get the seektable required for streaming AAC tracks pub async fn get_seektable(&self, file_id: &FileId) -> Result { - debug!("getting seektable {file_id}"); let url = format!( "https://seektables.scdn.co/seektable/{}.json", - file_id.base16() + file_id.to_base16()? ); Ok(self .session() @@ -992,14 +910,15 @@ impl SpClient { } pub async fn get_widevine_certificate(&self) -> Result { - debug!("getting widevine certificate"); - Ok(self - ._request_generic(RequestParams { - endpoint: "/widevine-license/v1/application-certificate", - ..Default::default() - }) - .await? - .data) + self._request_generic( + Method::GET, + "/widevine-license/v1/application-certificate", + None, + false, + None, + Vec::new(), + ) + .await } pub async fn get_widevine_license( @@ -1007,45 +926,35 @@ impl SpClient { challenge: Vec, is_video: bool, ) -> Result { - debug!("getting widevine license"); let media_type = if is_video { "video" } else { "audio" }; let url = format!("/widevine-license/v1/{media_type}/license"); - Ok(self - ._request_generic(RequestParams { - method: Method::POST, - endpoint: &url, - body: challenge, - ..Default::default() - }) - .await? - .data) + self._request_generic(Method::POST, &url, None, false, None, challenge) + .await } /// Get information about a track's artists, writers and producers - pub async fn get_track_credits(&self, track_id: TrackId<'_>) -> Result { - debug!("getting track credits for {track_id}"); + pub async fn get_track_credits(&self, track_id: &SpotifyId) -> Result { self.request_get_json(&format!( "/track-credits-view/v0/experimental/{}/credits", - track_id.id() + track_id.to_base62() )) .await } pub async fn pb_canvases( &self, - ids: impl IntoIterator> + Send, - ) -> Result, Canvaz>, Error> { + ids: &[&SpotifyId], + ) -> Result, Error> { let mut request = EntityCanvazRequest::new(); request.entities = ids - .into_iter() + .iter() .map(|id| { let mut entity = spotifyio_protocol::canvaz::entity_canvaz_request::Entity::new(); - entity.entity_uri = id.uri(); + entity.entity_uri = id.to_uri(); entity }) .collect(); - debug!("getting canvases for {} tracks", request.entities.len()); let resp = self .request_pb::(Method::POST, "/canvaz-cache/v0/canvases", &request) @@ -1053,15 +962,14 @@ impl SpClient { resp.canvases .into_iter() - .map(|c| Ok((TrackId::from_uri(&c.entity_uri)?.into_static(), c))) + .map(|c| Ok((SpotifyId::from_uri(&c.entity_uri)?, c))) .collect::, Error>>() } - pub async fn pb_canvas(&self, id: TrackId<'_>) -> Result { - debug!("getting canvas for {id}"); + pub async fn pb_canvas(&self, id: &SpotifyId) -> Result { let mut request = EntityCanvazRequest::new(); let mut entity = spotifyio_protocol::canvaz::entity_canvaz_request::Entity::new(); - entity.entity_uri = id.uri(); + entity.entity_uri = id.to_uri(); request.entities.push(entity); let resp = self @@ -1070,22 +978,20 @@ impl SpClient { resp.canvases .into_iter() .next() - .ok_or_else(|| Error::not_found(format!("canvas for {id}"))) + .ok_or(Error::not_found("no canvas found")) } - pub async fn get_lyrics(&self, id: TrackId<'_>) -> Result { - debug!("getting lyrics for {id}"); + pub async fn get_lyrics(&self, id: &SpotifyId) -> Result { let res = self - .request_get_json::(&format!("/color-lyrics/v2/track/{}", id.id())) + .request_get_json::(&format!("/color-lyrics/v2/track/{}", id.to_base62())) .await?; Ok(res.lyrics) } - pub async fn gql_artist_overview(&self, id: ArtistId<'_>) -> Result { - debug!("getting artist overview for {id}"); + pub async fn gql_artist_overview(&self, id: &SpotifyId) -> Result { let url = format!( "https://api-partner.spotify.com/pathfinder/v1/query?operationName=queryArtistOverview&variables=%7B%22uri%22%3A%22spotify%3Aartist%3A{}%22%2C%22locale%22%3A%22%22%2C%22includePrerelease%22%3Atrue%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22bc0107feab9595387a22ebed6c944c9cf72c81b2f72a3d26ac055e4465173a1f%22%7D%7D", - id.id() + id.to_base62() ); let res = self .request_get_json::>(&url) @@ -1093,11 +999,10 @@ impl SpClient { Ok(res.data.artist_union) } - pub async fn gql_artist_concerts(&self, id: ArtistId<'_>) -> Result, Error> { - debug!("getting artist concerts for {id}"); + pub async fn gql_artist_concerts(&self, id: &SpotifyId) -> Result, Error> { let url = format!( "https://api-partner.spotify.com/pathfinder/v1/query?operationName=artistConcerts&variables=%7B%22artistId%22%3A%22spotify%3Aartist%3A{}%22%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22ce78fdb28c5036de6d81bbc45571b62e2201a0c08eb068ab17dd3428396026f5%22%7D%7D", - id.id() + id.to_base62() ); let res = self .request_get_json::>(&url) @@ -1105,9 +1010,8 @@ impl SpClient { Ok(res.data.artist_union.goods.events.concerts.items) } - pub async fn gql_concert(&self, id: ConcertId<'_>) -> Result { - debug!("getting concert {id}"); - let cid = id.id(); + pub async fn gql_concert(&self, id: &SpotifyId) -> Result { + let cid = id.to_base62(); let url = format!( "https://api-partner.spotify.com/pathfinder/v1/query?operationName=concert&variables=%7B%22uri%22%3A%22spotify%3Aconcert%3A{}%22%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%2252470cde70b165f7dbf74fa9bcdff4eeee7e7eddadfe12bde54a25ef844abb38%22%7D%7D", cid @@ -1122,11 +1026,10 @@ impl SpClient { } } - pub async fn gql_prerelease(&self, id: PrereleaseId<'_>) -> Result { - debug!("getting prerelease {id}"); + pub async fn gql_prerelease(&self, id: &SpotifyId) -> Result { let url = format!( "https://api-partner.spotify.com/pathfinder/v1/query?operationName=albumPreRelease&variables=%7B%22uri%22%3A%22spotify%3Aprerelease%3A{}%22%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22cb7e121ae0c2d105ea9a8a5c8a003e520f333e0e94073032dcdbd548dd205d66%22%7D%7D", - id.id() + id.to_base62() ); let res = self .request_get_json::>(&url) @@ -1143,11 +1046,10 @@ impl SpClient { pub async fn gql_search( &self, query: &str, - offset: u32, - limit: u32, + offset: usize, + limit: usize, typ: Option, ) -> Result { - debug!("searching `{query}` (typ={typ:?},gql)"); let query = urlencoding::encode(query); let url = match typ { Some(SearchItemType::Artists) => format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=searchArtists&variables=%7B%22searchTerm%22%3A%22{query}%22%2C%22offset%22%3A{offset}%2C%22limit%22%3A{limit}%2C%22numberOfTopResults%22%3A20%2C%22includeAudiobooks%22%3Afalse%2C%22includePreReleases%22%3Afalse%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%220e6f9020a66fe15b93b3bb5c7e6484d1d8cb3775963996eaede72bac4d97e909%22%7D%7D"), @@ -1163,57 +1065,42 @@ impl SpClient { Ok(res.data.search_v2) } - pub async fn gql_playlist(&self, id: PlaylistId<'_>) -> Result { - debug!("getting playlist {id} (gql)"); - let url = format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=fetchPlaylist&variables=%7B%22uri%22%3A%22spotify%3Aplaylist%3A{}%22%2C%22offset%22%3A0%2C%22limit%22%3A0%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%2219ff1327c29e99c208c86d7a9d8f1929cfdf3d3202a0ff4253c821f1901aa94d%22%7D%7D", id.id()); - let res = self.request_get_json::>(&url).await?; - - res.data - .playlist_v2 - .into_option() - .ok_or_else(|| Error::not_found(format!("playlist {id}"))) - } - pub async fn get_user_profile( &self, - id: UserId<'_>, - playlist_limit: u32, + id: &SpotifyId, + playlist_limit: usize, ) -> Result { - debug!("getting user profile {id}"); self.request_get_json(&format!( "/user-profile-view/v3/profile/{}?playlist_limit={playlist_limit}&market=from_token", - id.id() + id.to_base62() )) .await } pub async fn get_user_playlists( &self, - id: UserId<'_>, - offset: u32, - limit: u32, - ) -> Result { - debug!("getting user playlists {id}"); - self.request_get_json(&format!("/user-profile-view/v3/profile/{}/playlists?offset={offset}&limit={limit}&market=from_token", id.id())).await + id: &SpotifyId, + offset: usize, + limit: usize, + ) -> Result { + self.request_get_json::(&format!("/user-profile-view/v3/profile/{}/playlists?offset={offset}&limit={limit}&market=from_token", id.to_base62())).await } - pub async fn get_user_followers(&self, id: UserId<'_>) -> Result, Error> { - debug!("getting user followers {id}"); + pub async fn get_user_followers(&self, id: &SpotifyId) -> Result, Error> { let res = self .request_get_json::(&format!( "/user-profile-view/v3/profile/{}/followers?market=from_token", - id.id() + id.to_base62() )) .await?; Ok(res.profiles) } - pub async fn get_user_following(&self, id: UserId<'_>) -> Result, Error> { - debug!("getting user following {id}"); + pub async fn get_user_following(&self, id: &SpotifyId) -> Result, Error> { let res = self .request_get_json::(&format!( "/user-profile-view/v3/profile/{}/following?market=from_token", - id.id() + id.to_base62() )) .await?; Ok(res.profiles) @@ -1229,13 +1116,12 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-track) pub async fn web_track( &self, - id: TrackId<'_>, + id: &SpotifyId, market: Option, ) -> Result { - debug!("getting track {id} (web)"); let params = build_map([("market", market.map(Into::into))]); - let url = format!("/tracks/{}", id.id()); + let url = format!("/tracks/{}", id.to_base62()); self.public_get(&url, Some(¶ms)).await } @@ -1246,13 +1132,12 @@ impl SpClient { /// - market - an ISO 3166-1 alpha-2 country code or the string from_token. /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-several-tracks) - pub async fn web_tracks( + pub async fn web_tracks<'a>( &self, - track_ids: impl IntoIterator> + Send, + track_ids: impl IntoIterator + Send, market: Option, ) -> Result, Error> { - let ids = join_ids(track_ids); - debug!("getting tracks: {ids} (web)"); + let ids = join_ids(track_ids)?; let params = build_map([("market", market.map(Into::into))]); let url = format!("/tracks/?ids={ids}"); @@ -1266,9 +1151,8 @@ impl SpClient { /// - artist_id - an artist ID, URI or URL /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artist) - pub async fn web_artist(&self, artist_id: ArtistId<'_>) -> Result { - debug!("getting artist {artist_id} (web)"); - let url = format!("/artists/{}", artist_id.id()); + pub async fn web_artist(&self, artist_id: &SpotifyId) -> Result { + let url = format!("/artists/{}", artist_id.to_base62()); self.public_get(&url, None).await } @@ -1278,12 +1162,11 @@ impl SpClient { /// - artist_ids - a list of artist IDs, URIs or URLs /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-artists) - pub async fn web_artists( + pub async fn web_artists<'a>( &self, - artist_ids: impl IntoIterator> + Send, + artist_ids: impl IntoIterator + Send + 'a, ) -> Result, Error> { - let ids = join_ids(artist_ids); - debug!("getting artists: {ids} (web)"); + let ids = join_ids(artist_ids)?; let url = format!("/artists/?ids={ids}"); let result = self.public_get::(&url, None).await?; Ok(result.artists) @@ -1304,15 +1187,15 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-albums) pub fn web_artist_albums<'a>( &'a self, - artist_id: ArtistId<'a>, + artist_id: &'a SpotifyId, include_groups: impl IntoIterator + Send + Copy + 'a, market: Option, - ) -> Paginator<'a, Result> { + ) -> Paginator<'_, Result> { paginate_with_ctx( (self, artist_id), move |(slf, artist_id), limit, offset| { Box::pin(slf.web_artist_albums_manual( - artist_id.as_ref(), + artist_id, include_groups, market, Some(limit), @@ -1324,15 +1207,14 @@ impl SpClient { } /// The manually paginated version of [`Self::artist_albums`]. - pub async fn web_artist_albums_manual( + pub async fn web_artist_albums_manual<'a>( &self, - artist_id: ArtistId<'_>, - include_groups: impl IntoIterator + Send, + artist_id: &'a SpotifyId, + include_groups: impl IntoIterator + Send + 'a, market: Option, limit: Option, offset: Option, ) -> Result, Error> { - debug!("getting albums of {artist_id} (web)"); let limit = limit.map(|x| x.to_string()); let offset = offset.map(|x| x.to_string()); let include_groups_vec = include_groups @@ -1352,7 +1234,7 @@ impl SpClient { ("offset", offset.as_deref()), ]); - let url = format!("/artists/{}/albums", artist_id.id()); + let url = format!("/artists/{}/albums", artist_id.to_base62()); self.public_get(&url, Some(¶ms)).await } @@ -1366,13 +1248,12 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-top-tracks) pub async fn web_artist_top_tracks( &self, - artist_id: ArtistId<'_>, + artist_id: &SpotifyId, market: Option, ) -> Result, Error> { - debug!("getting top tracks of {artist_id} (web)"); let params = build_map([("market", market.map(Into::into))]); - let url = format!("/artists/{}/top-tracks", artist_id.id()); + let url = format!("/artists/{}/top-tracks", artist_id.to_base62()); let result = self.public_get::(&url, Some(¶ms)).await?; Ok(result.tracks) } @@ -1387,10 +1268,9 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-related-artists) pub async fn web_artist_related_artists( &self, - artist_id: ArtistId<'_>, + artist_id: &SpotifyId, ) -> Result, Error> { - debug!("getting related artists of {artist_id} (web)"); - let url = format!("/artists/{}/related-artists", artist_id.id()); + let url = format!("/artists/{}/related-artists", artist_id.to_base62()); let result = self.public_get::(&url, None).await?; Ok(result.artists) } @@ -1403,13 +1283,12 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-album) pub async fn web_album( &self, - album_id: AlbumId<'_>, + album_id: &SpotifyId, market: Option, ) -> Result { - debug!("getting album {album_id} (web)"); let params = build_map([("market", market.map(Into::into))]); - let url = format!("/albums/{}", album_id.id()); + let url = format!("/albums/{}", album_id.to_base62()); self.public_get(&url, Some(¶ms)).await } @@ -1419,15 +1298,14 @@ impl SpClient { /// - albums_ids - a list of album IDs, URIs or URLs /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-albums) - pub async fn web_albums( + pub async fn web_albums<'a>( &self, - album_ids: impl IntoIterator> + Send, + album_ids: impl IntoIterator + Send + 'a, market: Option, ) -> Result, Error> { let params = build_map([("market", market.map(Into::into))]); - let ids = join_ids(album_ids); - debug!("getting albums: {ids} (web)"); + let ids = join_ids(album_ids)?; let url = format!("/albums/?ids={ids}"); let result = self.public_get::(&url, Some(¶ms)).await?; Ok(result.albums) @@ -1465,7 +1343,6 @@ impl SpClient { limit: Option, offset: Option, ) -> Result { - debug!("searching `{q}` (typ={typ:?}, web)"); let limit = limit.map(|s| s.to_string()); let offset = offset.map(|s| s.to_string()); let params = build_map([ @@ -1512,7 +1389,6 @@ impl SpClient { limit: Option, offset: Option, ) -> Result { - debug!("searching `{q}` (web)"); let limit = limit.map(|s| s.to_string()); let offset = offset.map(|s| s.to_string()); let mut _type = typ @@ -1544,32 +1420,26 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-albums-tracks) pub fn web_album_track<'a>( &'a self, - album_id: AlbumId<'a>, + album_id: &'a SpotifyId, market: Option, - ) -> Paginator<'a, Result> { + ) -> Paginator<'_, Result> { paginate_with_ctx( (self, album_id), move |(slf, album_id), limit, offset| { - Box::pin(slf.web_album_track_manual( - album_id.as_ref(), - market, - Some(limit), - Some(offset), - )) + Box::pin(slf.web_album_track_manual(album_id, market, Some(limit), Some(offset))) }, self.session().config().pagination_chunks, ) } /// The manually paginated version of [`Self::album_track`]. - pub async fn web_album_track_manual( + pub async fn web_album_track_manual<'a>( &self, - album_id: AlbumId<'_>, + album_id: &'a SpotifyId, market: Option, limit: Option, offset: Option, ) -> Result, Error> { - debug!("getting album tracks of {album_id} (web)"); let limit = limit.map(|s| s.to_string()); let offset = offset.map(|s| s.to_string()); let params = build_map([ @@ -1578,7 +1448,7 @@ impl SpClient { ("market", market.map(Into::into)), ]); - let url = format!("/albums/{}/tracks", album_id.id()); + let url = format!("/albums/{}/tracks", album_id.to_base62()); self.public_get(&url, Some(¶ms)).await } @@ -1588,9 +1458,8 @@ impl SpClient { /// - user - the id of the usr /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-users-profile) - pub async fn web_user(&self, user_id: UserId<'_>) -> Result { - debug!("getting user {user_id} (web)"); - let url = format!("/users/{}", user_id.id()); + pub async fn web_user(&self, user_id: &SpotifyId) -> Result { + let url = format!("/users/{}", user_id.to_base62()); self.public_get(&url, None).await } @@ -1603,14 +1472,13 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-playlist) pub async fn web_playlist( &self, - playlist_id: PlaylistId<'_>, + playlist_id: &SpotifyId, fields: Option<&str>, market: Option, ) -> Result { - debug!("getting playlist {playlist_id} (web)"); let params = build_map([("fields", fields), ("market", market.map(Into::into))]); - let url = format!("/playlists/{}", playlist_id.id()); + let url = format!("/playlists/{}", playlist_id.to_base62()); self.public_get(&url, Some(¶ms)).await } @@ -1624,16 +1492,19 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-list-users-playlists) pub async fn web_user_playlist( &self, - user_id: UserId<'_>, - playlist_id: Option>, + user_id: &SpotifyId, + playlist_id: Option<&SpotifyId>, fields: Option<&str>, ) -> Result { - debug!("getting user playlist from {user_id} ({playlist_id:?},web)"); let params = build_map([("fields", fields)]); let url = match playlist_id { - Some(playlist_id) => format!("/users/{}/playlists/{}", user_id.id(), playlist_id.id()), - None => format!("/users/{}/starred", user_id.id()), + Some(playlist_id) => format!( + "/users/{}/playlists/{}", + user_id.to_base62(), + playlist_id.to_base62() + ), + None => format!("/users/{}/starred", user_id.to_base62()), }; self.public_get(&url, Some(¶ms)).await } @@ -1646,17 +1517,15 @@ impl SpClient { /// follow the playlist. Maximum: 5 ids. /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/check-if-user-follows-playlist) - pub async fn web_playlist_check_follow( + pub async fn web_playlist_check_follow<'a>( &self, - playlist_id: PlaylistId<'_>, - user_ids: impl IntoIterator> + Send, + playlist_id: &'a SpotifyId, + user_ids: impl IntoIterator + Send, ) -> Result, Error> { - let ids = join_ids(user_ids); - debug!("checking followers of playlist {playlist_id}: {ids} (web)"); let url = format!( "/playlists/{}/followers/contains?ids={}", - playlist_id.id(), - ids, + playlist_id.to_base62(), + join_ids(user_ids)?, ); self.public_get(&url, None).await } @@ -1672,13 +1541,12 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-a-show) pub async fn web_get_a_show( &self, - id: ShowId<'_>, + id: &SpotifyId, market: Option, ) -> Result { - debug!("getting show {id} (web)"); let params = build_map([("market", market.map(Into::into))]); - let url = format!("/shows/{}", id.id()); + let url = format!("/shows/{}", id.to_base62()); self.public_get(&url, Some(¶ms)).await } @@ -1690,13 +1558,12 @@ impl SpClient { /// - market(Optional) An ISO 3166-1 alpha-2 country code or the string from_token. /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-shows) - pub async fn web_get_several_shows( + pub async fn web_get_several_shows<'a>( &self, - ids: impl IntoIterator> + Send, + ids: impl IntoIterator + Send + 'a, market: Option, ) -> Result, Error> { - let ids = join_ids(ids); - debug!("getting shows: {ids} (web)"); + let ids = join_ids(ids)?; let params = build_map([("ids", Some(&ids)), ("market", market.map(Into::into))]); let result = self @@ -1722,18 +1589,13 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-a-shows-episodes) pub fn web_get_shows_episodes<'a>( &'a self, - id: ShowId<'a>, + id: &'a SpotifyId, market: Option, - ) -> Paginator<'a, Result> { + ) -> Paginator<'_, Result> { paginate_with_ctx( (self, id), move |(slf, id), limit, offset| { - Box::pin(slf.web_get_shows_episodes_manual( - id.as_ref(), - market, - Some(limit), - Some(offset), - )) + Box::pin(slf.web_get_shows_episodes_manual(id, market, Some(limit), Some(offset))) }, self.session().config().pagination_chunks, ) @@ -1742,12 +1604,11 @@ impl SpClient { /// The manually paginated version of [`Self::get_shows_episodes`]. pub async fn web_get_shows_episodes_manual( &self, - id: ShowId<'_>, + id: &SpotifyId, market: Option, limit: Option, offset: Option, ) -> Result, Error> { - debug!("getting episodes of show {id} (web)"); let limit = limit.map(|x| x.to_string()); let offset = offset.map(|x| x.to_string()); let params = build_map([ @@ -1756,7 +1617,7 @@ impl SpClient { ("offset", offset.as_deref()), ]); - let url = format!("/shows/{}/episodes", id.id()); + let url = format!("/shows/{}/episodes", id.to_base62()); self.public_get(&url, Some(¶ms)).await } @@ -1771,12 +1632,11 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-episode) pub async fn web_get_an_episode( &self, - id: EpisodeId<'_>, + id: &SpotifyId, market: Option, ) -> Result { - debug!("getting episode {id} (web)"); let params = build_map([("market", market.map(Into::into))]); - let url = format!("/episodes/{}", id.id()); + let url = format!("/episodes/{}", id.to_base62()); self.public_get(&url, Some(¶ms)).await } @@ -1787,13 +1647,12 @@ impl SpClient { /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token. /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-episodes) - pub async fn web_get_several_episodes( + pub async fn web_get_several_episodes<'a>( &self, - ids: impl IntoIterator> + Send, + ids: impl IntoIterator + Send + 'a, market: Option, ) -> Result, Error> { - let ids = join_ids(ids); - debug!("getting episodes: {ids} (web)"); + let ids = join_ids(ids)?; let params = build_map([("ids", Some(&ids)), ("market", market.map(Into::into))]); let result = self @@ -1808,9 +1667,8 @@ impl SpClient { /// - track - track URI, URL or ID /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-audio-features) - pub async fn web_track_features(&self, track_id: TrackId<'_>) -> Result { - debug!("getting track features for {track_id} (web)"); - let url = format!("/audio-features/{}", track_id.id()); + pub async fn web_track_features(&self, track_id: &SpotifyId) -> Result { + let url = format!("/audio-features/{}", track_id.to_base62()); self.public_get(&url, None).await } @@ -1820,13 +1678,11 @@ impl SpClient { /// - tracks a list of track URIs, URLs or IDs /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-several-audio-features) - pub async fn web_tracks_features( + pub async fn web_tracks_features<'a>( &self, - track_ids: impl IntoIterator> + Send, + track_ids: impl IntoIterator + Send + 'a, ) -> Result, Error> { - let ids = join_ids(track_ids); - debug!("getting track features for {ids} (web)"); - let url = format!("/audio-features/?ids={ids}"); + let url = format!("/audio-features/?ids={}", join_ids(track_ids)?); let result = self .public_get::>(&url, None) @@ -1844,9 +1700,8 @@ impl SpClient { /// - track_id - a track URI, URL or ID /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-audio-analysis) - pub async fn web_track_analysis(&self, track_id: TrackId<'_>) -> Result { - debug!("getting audio analysis for {track_id} (web)"); - let url = format!("/audio-analysis/{}", track_id.id()); + pub async fn web_track_analysis(&self, track_id: &SpotifyId) -> Result { + let url = format!("/audio-analysis/{}", track_id.to_base62()); self.public_get(&url, None).await } @@ -1869,7 +1724,7 @@ impl SpClient { &'a self, locale: Option<&'a str>, country: Option, - ) -> Paginator<'a, Result> { + ) -> Paginator<'_, Result> { paginate( move |limit, offset| { self.web_categories_manual(locale, country, Some(limit), Some(offset)) @@ -1886,7 +1741,6 @@ impl SpClient { limit: Option, offset: Option, ) -> Result, Error> { - debug!("getting categories (web)"); let limit = limit.map(|x| x.to_string()); let offset = offset.map(|x| x.to_string()); let params = build_map([ @@ -1919,7 +1773,7 @@ impl SpClient { &'a self, category_id: &'a str, country: Option, - ) -> Paginator<'a, Result> { + ) -> Paginator<'_, Result> { paginate( move |limit, offset| { self.web_category_playlists_manual(category_id, country, Some(limit), Some(offset)) @@ -1936,7 +1790,6 @@ impl SpClient { limit: Option, offset: Option, ) -> Result, Error> { - debug!("getting playlists of category {category_id} (web)"); let limit = limit.map(|x| x.to_string()); let offset = offset.map(|x| x.to_string()); let params = build_map([ @@ -1956,7 +1809,6 @@ impl SpClient { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-current-users-profile) pub async fn web_current_user(&self) -> Result { - debug!("getting current user (web)"); self.public_get("/me", None).await } @@ -1985,7 +1837,6 @@ impl SpClient { limit: Option, offset: Option, ) -> Result { - debug!("getting featured playlists (web)"); let limit = limit.map(|x| x.to_string()); let offset = offset.map(|x| x.to_string()); let timestamp = timestamp.and_then(|x| { @@ -2034,7 +1885,6 @@ impl SpClient { limit: Option, offset: Option, ) -> Result, Error> { - debug!("getting new releases (web)"); let limit = limit.map(|x| x.to_string()); let offset = offset.map(|x| x.to_string()); let params = build_map([ @@ -2066,18 +1916,18 @@ impl SpClient { /// targeting on results. /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-recommendations) - pub async fn web_recommendations( + pub async fn web_recommendations<'a>( &self, - attributes: impl IntoIterator + Send, - seed_artists: Option> + Send>, - seed_genres: Option + Send>, - seed_tracks: Option> + Send>, + attributes: impl IntoIterator + Send + 'a, + seed_artists: Option + Send + 'a>, + seed_genres: Option + Send + 'a>, + seed_tracks: Option + Send + 'a>, market: Option, limit: Option, ) -> Result { - let seed_artists = seed_artists.map(|a| join_ids(a)); + let seed_artists = seed_artists.and_then(|a| join_ids(a).ok()); let seed_genres = seed_genres.map(|x| x.into_iter().collect::>().join(",")); - let seed_tracks = seed_tracks.map(|t| join_ids(t)); + let seed_tracks = seed_tracks.and_then(|t| join_ids(t).ok()); let limit = limit.map(|x| x.to_string()); let mut params = build_map([ ("seed_artists", seed_artists.as_deref()), @@ -2086,7 +1936,6 @@ impl SpClient { ("market", market.map(Into::into)), ("limit", limit.as_deref()), ]); - debug!("getting recommendations (artists={seed_artists:?},genres={seed_genres:?},tracks={seed_tracks:?},web)"); // First converting the attributes into owned `String`s let owned_attributes = attributes @@ -2119,15 +1968,15 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-playlists-tracks) pub async fn web_playlist_items<'a>( &'a self, - playlist_id: PlaylistId<'a>, + playlist_id: &'a SpotifyId, fields: Option<&'a str>, market: Option, - ) -> Paginator<'a, Result> { + ) -> Paginator<'_, Result> { paginate_with_ctx( (self, playlist_id, fields), move |(slf, playlist_id, fields), limit, offset| { Box::pin(slf.web_playlist_items_manual( - playlist_id.as_ref(), + playlist_id, *fields, market, Some(limit), @@ -2141,13 +1990,12 @@ impl SpClient { /// The manually paginated version of [`Self::playlist_items`]. pub async fn web_playlist_items_manual( &self, - playlist_id: PlaylistId<'_>, + playlist_id: &SpotifyId, fields: Option<&str>, market: Option, limit: Option, offset: Option, ) -> Result, Error> { - debug!("getting items of playlist {playlist_id} (web)"); let limit = limit.map(|s| s.to_string()); let offset = offset.map(|s| s.to_string()); let params = build_map([ @@ -2157,7 +2005,7 @@ impl SpClient { ("offset", offset.as_deref()), ]); - let url = format!("/playlists/{}/tracks", playlist_id.id()); + let url = format!("/playlists/{}/tracks", playlist_id.to_base62()); self.public_get(&url, Some(¶ms)).await } @@ -2174,12 +2022,12 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-list-users-playlists) pub fn web_user_playlists<'a>( &'a self, - user_id: UserId<'a>, - ) -> Paginator<'a, Result> { + user_id: &'a SpotifyId, + ) -> Paginator<'_, Result> { paginate_with_ctx( (self, user_id), move |(slf, user_id), limit, offset| { - Box::pin(slf.web_user_playlists_manual(user_id.as_ref(), Some(limit), Some(offset))) + Box::pin(slf.web_user_playlists_manual(user_id, Some(limit), Some(offset))) }, self.session().config().pagination_chunks, ) @@ -2188,16 +2036,15 @@ impl SpClient { /// The manually paginated version of [`Self::user_playlists`]. pub async fn web_user_playlists_manual( &self, - user_id: UserId<'_>, + user_id: &SpotifyId, limit: Option, offset: Option, ) -> Result, Error> { - debug!("getting playlists of user {user_id} (web)"); let limit = limit.map(|s| s.to_string()); let offset = offset.map(|s| s.to_string()); let params = build_map([("limit", limit.as_deref()), ("offset", offset.as_deref())]); - let url = format!("/users/{}/playlists", user_id.id()); + let url = format!("/users/{}/playlists", user_id.to_base62()); self.public_get(&url, Some(¶ms)).await } } @@ -2216,21 +2063,20 @@ fn build_map<'key, 'value, const N: usize>( map } -fn join_ids(ids: impl IntoIterator) -> String { - ids.into_iter() - .map(|id| id.id().to_owned()) +fn join_ids<'a>(ids: impl IntoIterator) -> Result { + Ok(ids + .into_iter() + .map(SpotifyId::to_base62) .collect::>() - .join(",") + .join(",")) } #[cfg(test)] mod tests { use futures_util::TryStreamExt; - use spotifyio_model::{ - ArtistId, FileId, IdConstruct, PlaylistId, PrereleaseId, TrackId, UserId, - }; + use spotifyio_model::FileId; - use crate::{cache::SessionCache, gql_model::SearchItemType, Session, SessionConfig}; + use crate::{cache::SessionCache, model::SearchItemType, Session, SessionConfig, SpotifyId}; async fn conn() -> Session { let s = Session::new(SessionConfig::default(), SessionCache::testing()); @@ -2243,37 +2089,18 @@ mod tests { let s = conn().await; let artist = s .spclient() - .pb_artist( - ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap(), - // Some("\"MC-EvTsdg==\""), - None, - ) + .pb_artist(&SpotifyId::from_base62("1EfwyuCzDQpCslZc8C9gkG").unwrap()) .await .unwrap(); dbg!(&artist); } - #[tokio::test] - async fn get_artists() { - let s = conn().await; - let artists = s - .spclient() - .pb_artists([ - ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap(), - ArtistId::from_id("5RJFJWYgtgWktosLrUDzxx").unwrap(), // does not exist - ArtistId::from_id("2NpPlwwDVYR5dIj0F31EcC").unwrap(), - ]) - .await - .unwrap(); - dbg!(&artists); - } - #[tokio::test] async fn get_artist_overview() { let s = conn().await; let artist = s .spclient() - .gql_artist_overview(ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap()) + .gql_artist_overview(&SpotifyId::from_base62("1EfwyuCzDQpCslZc8C9gkG").unwrap()) .await .unwrap(); dbg!(&artist); @@ -2285,7 +2112,7 @@ mod tests { let search = s .spclient() .gql_prerelease( - PrereleaseId::from_uri("spotify:prerelease:44eAZjMWAxN7AaRHqLHWO5").unwrap(), + &SpotifyId::from_uri("spotify:prerelease:44eAZjMWAxN7AaRHqLHWO5").unwrap(), ) .await .unwrap(); @@ -2312,7 +2139,7 @@ mod tests { .gql_search("this is me", 0, n, Some(SearchItemType::Tracks)) .await .unwrap(); - assert_eq!(search.tracks_v2.items.len(), n as usize); + assert_eq!(search.tracks_v2.items.len(), n); dbg!(&search); } @@ -2325,7 +2152,7 @@ mod tests { .gql_search("test", 0, n, Some(SearchItemType::Users)) .await .unwrap(); - assert_eq!(search.users.items.len(), n as usize); + assert_eq!(search.users.items.len(), n); dbg!(&search); } @@ -2334,7 +2161,10 @@ mod tests { let s = conn().await; let user = s .spclient() - .get_user_profile(UserId::from_id("ustz0fgnbb2pjpjhu3num7b91").unwrap(), 20) + .get_user_profile( + &SpotifyId::from_base62("ustz0fgnbb2pjpjhu3num7b91").unwrap(), + 20, + ) .await .unwrap(); dbg!(&user); @@ -2346,7 +2176,7 @@ mod tests { let playlists = s .spclient() .get_user_playlists( - UserId::from_id("ustz0fgnbb2pjpjhu3num7b91").unwrap(), + &SpotifyId::from_base62("ustz0fgnbb2pjpjhu3num7b91").unwrap(), 0, 200, ) @@ -2360,7 +2190,7 @@ mod tests { let s = conn().await; let followers = s .spclient() - .get_user_followers(UserId::from_id("c4vns19o05omhfw0p4txxvya7").unwrap()) + .get_user_followers(&SpotifyId::from_base62("c4vns19o05omhfw0p4txxvya7").unwrap()) .await .unwrap(); dbg!(&followers); @@ -2371,7 +2201,7 @@ mod tests { let s = conn().await; let followers = s .spclient() - .get_user_following(UserId::from_id("c4vns19o05omhfw0p4txxvya7").unwrap()) + .get_user_following(&SpotifyId::from_base62("c4vns19o05omhfw0p4txxvya7").unwrap()) .await .unwrap(); dbg!(&followers); @@ -2383,7 +2213,7 @@ mod tests { let albums = s .spclient() .web_artist_albums( - ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap(), + &SpotifyId::from_base62("1EfwyuCzDQpCslZc8C9gkG").unwrap(), None, None, ) @@ -2399,7 +2229,7 @@ mod tests { let track = s .spclient() .web_track( - TrackId::from_id("4Lzbwc1IKHCDddL17xJjxV").unwrap(), + &SpotifyId::from_base62("4Lzbwc1IKHCDddL17xJjxV").unwrap(), Some(spotifyio_model::Market::FromToken), ) .await @@ -2433,15 +2263,4 @@ mod tests { let license_b64 = data_encoding::BASE64.encode(&license); dbg!(&license_b64); } - - #[tokio::test] - async fn web_playlist() { - let s = conn().await; - let pl = s - .spclient() - .gql_playlist(PlaylistId::from_id("1Th7TfDCbL8I9v6nRhWObF").unwrap()) - .await - .unwrap(); - dbg!(&pl); - } } diff --git a/crates/spotifyio/src/util.rs b/crates/spotifyio/src/util.rs index 71d430c..430691c 100644 --- a/crates/spotifyio/src/util.rs +++ b/crates/spotifyio/src/util.rs @@ -89,25 +89,6 @@ pub fn audio_format_mime(format: AudioFileFormat) -> Option<&'static str> { } } -/// Get the bitrate of the audio format in kbit/s -pub fn audio_format_bitrate(format: AudioFileFormat) -> Option { - match format { - AudioFileFormat::OGG_VORBIS_96 | AudioFileFormat::MP3_96 => Some(96000), - AudioFileFormat::OGG_VORBIS_160 - | AudioFileFormat::AAC_160 - | AudioFileFormat::MP3_160 - | AudioFileFormat::MP3_160_ENC => Some(160000), - AudioFileFormat::OGG_VORBIS_320 | AudioFileFormat::AAC_320 | AudioFileFormat::MP3_320 => { - Some(320000) - } - AudioFileFormat::MP4_128 => Some(128000), - AudioFileFormat::MP3_256 | AudioFileFormat::MP4_256 => Some(256000), - AudioFileFormat::AAC_24 => Some(24000), - AudioFileFormat::AAC_48 => Some(48000), - AudioFileFormat::FLAC_FLAC | AudioFileFormat::UNKNOWN_FORMAT => None, - } -} - pub fn audio_format_available(format: AudioFileFormat, is_premium: bool) -> bool { match format { AudioFileFormat::OGG_VORBIS_96