From 1599c854226ae40b1277c92e1887705674a1b9ff Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Thu, 28 Nov 2024 12:58:40 +0100 Subject: [PATCH 01/10] chore: prerelease model v0.2.0 --- Cargo.toml | 2 +- crates/model/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b05fbe1..5c29632 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,4 +16,4 @@ protobuf = "3.5" # WS crates 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.1.0", registry = "thetadev" } +spotifyio-model = { path = "crates/model", version = "0.2.0", registry = "thetadev" } diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index 0aeee7b..7e10d4d 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spotifyio-model" -version = "0.1.0" +version = "0.2.0" authors = [ "Ramsay Leung ", "Mario Ortiz Manero ", From 346a910c21d0fddae1ab857b03632985502d7fcf Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Thu, 28 Nov 2024 13:00:06 +0100 Subject: [PATCH 02/10] chore: prerelease spotifyio v0.0.2 --- Cargo.toml | 2 +- crates/spotifyio/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5c29632..41440d9 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.1", registry = "thetadev" } +spotifyio = { path = "crates/spotifyio", version = "0.0.2", registry = "thetadev" } spotifyio-protocol = { path = "crates/protocol", version = "0.1.0", registry = "thetadev" } spotifyio-model = { path = "crates/model", version = "0.2.0", registry = "thetadev" } diff --git a/crates/spotifyio/Cargo.toml b/crates/spotifyio/Cargo.toml index 4f499d7..497885e 100644 --- a/crates/spotifyio/Cargo.toml +++ b/crates/spotifyio/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spotifyio" -version = "0.0.1" +version = "0.0.2" description = "Internal Spotify API Client" edition.workspace = true authors.workspace = true From 5b50589b26efdc2985db398134d5ff8e6cae3cc0 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 29 Nov 2024 19:27:19 +0100 Subject: [PATCH 03/10] feat: add Etag queries to pb client, client logging --- crates/cli/Cargo.toml | 2 + crates/cli/src/main.rs | 64 ++- ...1dc37a92e9f9fbba314290e59c0cc7428158c.json | 12 + ...597624a633817d76ac34dd6378e7210b644e5.json | 12 + ...846ce0be655af44a1fb7bf0501f31bc5ad715.json | 20 + ...fcc93c1d98800fb4b771724fec8b38b6cbbae.json | 20 + ...6565f194f575c8de0c6f57f7eb8ff21b65b8f.json | 32 ++ ...ff81e77b5871b5bf74bc9c6faf7658a946980.json | 12 + ...71af96993137b373c6647134853e93c57c923.json | 20 + ...8eacddd65f0746c68243a27af1ec128dad3d1.json | 12 + ...837738be313b9cbc7e297ac15530efbaa7e3d.json | 12 + ...0703b5650aeb72f4253c9eddea533707aff73.json | 20 + ...d03ecfa24f0237c40666088bacc7893a101ff.json | 12 + ...55be99eefc34938bab5ccdc0f01c0d1fa9e7d.json | 20 + .../migrations/20241129160324_artist_etag.sql | 1 + crates/downloader/src/lib.rs | 50 +- crates/downloader/src/main.rs | 2 +- crates/model/src/spotify_id.rs | 8 +- crates/spotifyio/Cargo.toml | 1 + crates/spotifyio/src/error.rs | 27 +- crates/spotifyio/src/lib.rs | 7 +- crates/spotifyio/src/model.rs | 1 + crates/spotifyio/src/spclient.rs | 500 ++++++++++++------ 23 files changed, 645 insertions(+), 222 deletions(-) create mode 100644 crates/downloader/.sqlx/query-07c4c0a0ba528964a13c8c9f8771dc37a92e9f9fbba314290e59c0cc7428158c.json create mode 100644 crates/downloader/.sqlx/query-17be8832946ce5585d93cb4e76d597624a633817d76ac34dd6378e7210b644e5.json create mode 100644 crates/downloader/.sqlx/query-419ae5a6ca51a68acf24c8fc46f846ce0be655af44a1fb7bf0501f31bc5ad715.json create mode 100644 crates/downloader/.sqlx/query-4c6d9967a54a12626f864c6e388fcc93c1d98800fb4b771724fec8b38b6cbbae.json create mode 100644 crates/downloader/.sqlx/query-77f3d314857a7d101506c00d4416565f194f575c8de0c6f57f7eb8ff21b65b8f.json create mode 100644 crates/downloader/.sqlx/query-84a7b6606d0e2f04c40c7bae39cff81e77b5871b5bf74bc9c6faf7658a946980.json create mode 100644 crates/downloader/.sqlx/query-8aa211d6b230db73f13394bb7a671af96993137b373c6647134853e93c57c923.json create mode 100644 crates/downloader/.sqlx/query-a26fa646feb96469fcfaae8cd118eacddd65f0746c68243a27af1ec128dad3d1.json create mode 100644 crates/downloader/.sqlx/query-a59e3b332fa9fb25c39f2d788fa837738be313b9cbc7e297ac15530efbaa7e3d.json create mode 100644 crates/downloader/.sqlx/query-a9e2d1d9656ad3f059dc1c17ab60703b5650aeb72f4253c9eddea533707aff73.json create mode 100644 crates/downloader/.sqlx/query-d75195ed340e3961933bbe69946d03ecfa24f0237c40666088bacc7893a101ff.json create mode 100644 crates/downloader/.sqlx/query-de9c04eb8f8f961579e0b58244355be99eefc34938bab5ccdc0f01c0d1fa9e7d.json create mode 100644 crates/downloader/migrations/20241129160324_artist_etag.sql diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 6a3bdef..ff5a484 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -20,5 +20,7 @@ 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 4aad0e3..a706351 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -6,7 +6,7 @@ use futures_util::{stream, StreamExt, TryStreamExt}; use path_macro::path; use spotifyio::{ pb::AudioFileFormat, ApplicationCache, AuthCredentials, AuthenticationType, CdnUrl, FileId, - PoolConfig, Session, SessionConfig, SpotifyId, SpotifyIoPool, + IdError, PoolConfig, Session, SessionConfig, SpotifyId, SpotifyIoPool, }; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; @@ -54,6 +54,7 @@ enum Commands { #[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()) @@ -109,10 +110,15 @@ async fn main() -> Result<(), Error> { ..Default::default() }, )?; - let spotify_id = SpotifyId::from_base62(&id).unwrap(); + let spotify_id = parse_idstr(&id).unwrap(); - let track = pool.spclient()?.pb_track(&spotify_id).await?; - dbg!(&track); + 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 spotify_id = SpotifyId::from_raw(track.gid()).unwrap(); println!("Track id: {}", spotify_id.to_base62()); @@ -145,16 +151,14 @@ async fn main() -> Result<(), Error> { }, )?; - let id = SpotifyId::from_base62(&id)?; - let album = pool.spclient()?.pb_album(&id).await?; + let id = parse_idstr(&id)?; + let album_et = pool.spclient()?.pb_album(&id, None).await?; - dbg!(&album); - - for disc in album.disc { - for track in disc.track { - println!("{}", SpotifyId::from_raw(track.gid())?.to_base62()) - } - } + tracing::info!("ETag: {}", album_et.etag.unwrap_or_default()); + println!( + "{}", + protobuf_json_mapping::print_to_string(&album_et.data).unwrap() + ); } Commands::Artist { id } => { let pool = SpotifyIoPool::from_app_cache( @@ -166,10 +170,13 @@ async fn main() -> Result<(), Error> { }, )?; - let id = SpotifyId::from_base62(&id)?; - let artist = pool.spclient()?.pb_artist(&id).await?; - - dbg!(&artist); + 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::Prerelease { id } => { let pool = SpotifyIoPool::from_app_cache( @@ -180,9 +187,9 @@ async fn main() -> Result<(), Error> { ..Default::default() }, )?; - let id = SpotifyId::from_base62(&id)?; + let id = parse_idstr(&id)?; let prerelease = pool.spclient()?.gql_prerelease(&id).await?; - dbg!(&prerelease); + println!("{}", serde_json::to_string(&prerelease).unwrap()); } Commands::Ratelimit { ids } => { let pool = SpotifyIoPool::from_app_cache( @@ -192,7 +199,7 @@ async fn main() -> Result<(), Error> { )?; let ids = std::fs::read_to_string(ids)? .lines() - .filter_map(|line| SpotifyId::from_base62(line).ok()) + .filter_map(|line| parse_idstr(line).ok()) .collect::>(); let client = pool.spclient()?; @@ -250,8 +257,8 @@ async fn main() -> Result<(), Error> { ..Default::default() }, )?; - let spid = SpotifyId::from_base62(&id)?; - let track = pool.spclient()?.pb_track(&spid).await?; + let spid = parse_idstr(&id)?; + let track = pool.spclient()?.pb_track(&spid, None).await?.data; let mp4_file = track .file .iter() @@ -295,7 +302,7 @@ async fn main() -> Result<(), Error> { pool.connect().await.unwrap(); let stdout = console::Term::buffered_stdout(); - let spotify_id = SpotifyId::from_base62("4AY7RuEn7nmnxtvya5ygCt").unwrap(); + let spotify_id = parse_idstr("4AY7RuEn7nmnxtvya5ygCt").unwrap(); let file_id = FileId::from_base16("560ad6f0d543aa20e7851f891b4e40ba47d54b54").unwrap(); loop { @@ -314,3 +321,14 @@ async fn main() -> Result<(), Error> { } Ok(()) } + +fn parse_idstr(s: &str) -> Result { + if s.ends_with("==") { + let bytes = data_encoding::BASE64 + .decode(s.as_bytes()) + .map_err(|_| IdError::InvalidId)?; + SpotifyId::from_raw(&bytes) + } else { + SpotifyId::from_id_or_uri(s) + } +} diff --git a/crates/downloader/.sqlx/query-07c4c0a0ba528964a13c8c9f8771dc37a92e9f9fbba314290e59c0cc7428158c.json b/crates/downloader/.sqlx/query-07c4c0a0ba528964a13c8c9f8771dc37a92e9f9fbba314290e59c0cc7428158c.json new file mode 100644 index 0000000..acde44b --- /dev/null +++ b/crates/downloader/.sqlx/query-07c4c0a0ba528964a13c8c9f8771dc37a92e9f9fbba314290e59c0cc7428158c.json @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 0000000..d2baac1 --- /dev/null +++ b/crates/downloader/.sqlx/query-17be8832946ce5585d93cb4e76d597624a633817d76ac34dd6378e7210b644e5.json @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 0000000..a628758 --- /dev/null +++ b/crates/downloader/.sqlx/query-419ae5a6ca51a68acf24c8fc46f846ce0be655af44a1fb7bf0501f31bc5ad715.json @@ -0,0 +1,20 @@ +{ + "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 new file mode 100644 index 0000000..f7f8064 --- /dev/null +++ b/crates/downloader/.sqlx/query-4c6d9967a54a12626f864c6e388fcc93c1d98800fb4b771724fec8b38b6cbbae.json @@ -0,0 +1,20 @@ +{ + "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 new file mode 100644 index 0000000..149723e --- /dev/null +++ b/crates/downloader/.sqlx/query-77f3d314857a7d101506c00d4416565f194f575c8de0c6f57f7eb8ff21b65b8f.json @@ -0,0 +1,32 @@ +{ + "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 new file mode 100644 index 0000000..edb79d7 --- /dev/null +++ b/crates/downloader/.sqlx/query-84a7b6606d0e2f04c40c7bae39cff81e77b5871b5bf74bc9c6faf7658a946980.json @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 0000000..80dd1c3 --- /dev/null +++ b/crates/downloader/.sqlx/query-8aa211d6b230db73f13394bb7a671af96993137b373c6647134853e93c57c923.json @@ -0,0 +1,20 @@ +{ + "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 new file mode 100644 index 0000000..c570c69 --- /dev/null +++ b/crates/downloader/.sqlx/query-a26fa646feb96469fcfaae8cd118eacddd65f0746c68243a27af1ec128dad3d1.json @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 0000000..27dcddd --- /dev/null +++ b/crates/downloader/.sqlx/query-a59e3b332fa9fb25c39f2d788fa837738be313b9cbc7e297ac15530efbaa7e3d.json @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 0000000..56420e3 --- /dev/null +++ b/crates/downloader/.sqlx/query-a9e2d1d9656ad3f059dc1c17ab60703b5650aeb72f4253c9eddea533707aff73.json @@ -0,0 +1,20 @@ +{ + "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 new file mode 100644 index 0000000..78c4481 --- /dev/null +++ b/crates/downloader/.sqlx/query-d75195ed340e3961933bbe69946d03ecfa24f0237c40666088bacc7893a101ff.json @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 0000000..16b3192 --- /dev/null +++ b/crates/downloader/.sqlx/query-de9c04eb8f8f961579e0b58244355be99eefc34938bab5ccdc0f01c0d1fa9e7d.json @@ -0,0 +1,20 @@ +{ + "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/migrations/20241129160324_artist_etag.sql b/crates/downloader/migrations/20241129160324_artist_etag.sql new file mode 100644 index 0000000..b73d2f8 --- /dev/null +++ b/crates/downloader/migrations/20241129160324_artist_etag.sql @@ -0,0 +1 @@ +alter table artists add column etag TEXT; diff --git a/crates/downloader/src/lib.rs b/crates/downloader/src/lib.rs index 0a6488c..6b033c7 100644 --- a/crates/downloader/src/lib.rs +++ b/crates/downloader/src/lib.rs @@ -28,8 +28,8 @@ use path_macro::path; use serde::Serialize; use spotifyio::{ pb::AudioFileFormat, AudioKey, CdnUrl, Error as SpotifyError, FileId, IdError, - NormalisationData, PoolConfig, PoolError, Quota, Session, SessionConfig, SpotifyId, - SpotifyIoPool, SpotifyItemType, + NormalisationData, NotModifiedRes, PoolConfig, PoolError, Quota, Session, SessionConfig, + SpotifyId, SpotifyIoPool, SpotifyItemType, }; use spotifyio_protocol::metadata::{Album, Availability, Restriction, Track}; use sqlx::{sqlite::SqliteConnectOptions, ConnectOptions, SqlitePool}; @@ -387,11 +387,11 @@ impl SpotifyDownloader { let session = self.i.sp.session()?; match spotify_id.item_type { SpotifyItemType::Track => { - let track = session.spclient().pb_track(spotify_id).await?; + let track = session.spclient().pb_track(spotify_id, None).await?.data; map_track(session, track) } SpotifyItemType::Episode => { - let episode = session.spclient().pb_episode(spotify_id).await?; + let episode = session.spclient().pb_episode(spotify_id, None).await?.data; Ok(AudioItem { track_id: SpotifyId::from_raw(episode.gid())?.episode(), actual_id: None, @@ -545,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).await?; + let artist = self.i.sp.spclient()?.pb_artist(artist_id, None).await?.data; artist.genre.first().map(|s| capitalize(s)) }; @@ -575,7 +575,7 @@ 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).await?; + let album = self.i.sp.spclient()?.pb_album(album_id, None).await?.data; album_n_discs(&album) }; N_DISCS_CACHE.write().unwrap().insert(aid_str, final_res); @@ -744,8 +744,9 @@ impl SpotifyDownloader { let img_path = path!(artist_dir / "artist.jpg"); let artist = sess .spclient() - .pb_artist(&SpotifyId::from_base62(&album_artist.id)?) - .await?; + .pb_artist(&SpotifyId::from_base62(&album_artist.id)?, None) + .await? + .data; let image = artist .portrait_group @@ -988,9 +989,25 @@ impl SpotifyDownloader { false } - pub async fn download_artist(&self, artist_id: &SpotifyId) -> Result { + pub async fn download_artist( + &self, + artist_id: &SpotifyId, + if_none_match: Option<&str>, + ) -> Result { let artist_id_str = artist_id.to_base62(); - let artist = self.i.sp.spclient()?.pb_artist(artist_id).await?; + let artist_res = self + .i + .sp + .spclient()? + .pb_artist(artist_id, 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; let artist_name = artist.name.unwrap_or_default(); let album_ids = artist .album_group @@ -1016,9 +1033,10 @@ impl SpotifyDownloader { if success { let now = OffsetDateTime::now_utc().unix_timestamp(); let res = sqlx::query!( - "update artists set last_update=$2 where id=$1", + "update artists set last_update=$2, etag=$3 where id=$1", artist_id_str, now, + artist_et.etag, ) .execute(&self.i.pool) .await; @@ -1035,7 +1053,7 @@ impl SpotifyDownloader { 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 album = self.i.sp.spclient()?.pb_album(album_id, None).await?.data; let row = sqlx::query!(r#"select id from albums where id=$1"#, album_id_str) .fetch_optional(&self.i.pool) @@ -1098,18 +1116,18 @@ impl SpotifyDownloader { m.add(pb) }); sqlx::query!( - "select id, name from artists where active=1 and (last_update is null or last_update < $1)", + "select id, name, etag 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, |x| { + .try_for_each_concurrent(4, |row| { if let Some(progress) = &progress { progress.inc(1); } - let artist_id = SpotifyId::from_base62(&x.id.unwrap()).unwrap().artist(); + let artist_id = SpotifyId::from_base62(&row.id.unwrap()).unwrap().artist(); async move { - self.download_artist(&artist_id).await?; + self.download_artist(&artist_id, row.etag.as_deref()).await?; Ok(()) } }) diff --git a/crates/downloader/src/main.rs b/crates/downloader/src/main.rs index 53dcbf2..3334af4 100644 --- a/crates/downloader/src/main.rs +++ b/crates/downloader/src/main.rs @@ -284,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.artist()).await?; + let ok = dl.download_artist(&id.artist(), None).await?; if !ok { success.store(false, Ordering::SeqCst); } diff --git a/crates/model/src/spotify_id.rs b/crates/model/src/spotify_id.rs index ca108e6..f070ea2 100644 --- a/crates/model/src/spotify_id.rs +++ b/crates/model/src/spotify_id.rs @@ -383,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_uri()) + f.write_str(&self.to_base62()) } } @@ -403,7 +403,7 @@ impl<'de> Deserialize<'de> for SpotifyId { { struct SpotifyIdVisitor; - impl<'de> Visitor<'de> for SpotifyIdVisitor { + impl Visitor<'_> for SpotifyIdVisitor { type Value = SpotifyId; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { @@ -429,7 +429,7 @@ pub mod ser { struct SpecificIdVisitor; - impl<'de> Visitor<'de> for SpecificIdVisitor { + impl Visitor<'_> for SpecificIdVisitor { type Value = SpotifyId; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { @@ -446,7 +446,7 @@ pub mod ser { struct OptionalSpecificIdVisitor; - impl<'de> Visitor<'de> for OptionalSpecificIdVisitor { + impl Visitor<'_> for OptionalSpecificIdVisitor { type Value = Option; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { diff --git a/crates/spotifyio/Cargo.toml b/crates/spotifyio/Cargo.toml index 497885e..6ee0375 100644 --- a/crates/spotifyio/Cargo.toml +++ b/crates/spotifyio/Cargo.toml @@ -83,6 +83,7 @@ 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/error.rs b/crates/spotifyio/src/error.rs index bfa9f97..c46643a 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("Unrecoverable data loss or corruption")] - DataLoss = 15, + #[error("Entity has not been modified since the last request")] + NotModified = 15, #[error("Operation must not be used")] DoNotUse = -1, @@ -127,12 +127,12 @@ impl Error { } } - pub fn data_loss(error: E) -> Error + pub fn not_modified(error: E) -> Error where E: Into>, { Self { - kind: ErrorKind::DataLoss, + kind: ErrorKind::NotModified, error: error.into(), } } @@ -451,3 +451,22 @@ 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 c93fdd6..c227556 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, clippy::dbg_macro)] +#![warn(clippy::todo)] #[macro_use] mod component; @@ -37,12 +37,12 @@ pub use audio_key::{AudioKey, AudioKeyError}; pub use authentication::AuthCredentials; pub use cache::{ApplicationCache, SessionCache}; pub use cdn_url::{CdnUrl, CdnUrlError}; -pub use error::{Error, ErrorKind}; +pub use error::{Error, ErrorKind, NotModifiedRes}; pub use governor::{Jitter, Quota}; pub use normalisation::NormalisationData; pub use pool::PoolError; pub use session::{Session, SessionConfig}; -pub use spclient::RequestStrategy; +pub use spclient::{EtagResponse, RequestStrategy}; pub use spotifyio_model::{ AlbumType, FileId, IdError, IncludeExternal, Market, RecommendationsAttribute, SearchResult, SearchType, SpotifyId, SpotifyItemType, @@ -196,6 +196,7 @@ impl SpotifyIoPool { } #[cfg(test)] + #[allow(unused)] pub(crate) fn testing() -> Self { use path_macro::path; diff --git a/crates/spotifyio/src/model.rs b/crates/spotifyio/src/model.rs index da7c581..91c291d 100644 --- a/crates/spotifyio/src/model.rs +++ b/crates/spotifyio/src/model.rs @@ -469,6 +469,7 @@ pub struct ConcertOfferDates { pub end_date_iso_string: OffsetDateTime, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SearchItemType { Artists, Albums, diff --git a/crates/spotifyio/src/spclient.rs b/crates/spotifyio/src/spclient.rs index e7c2990..92a06ee 100644 --- a/crates/spotifyio/src/spclient.rs +++ b/crates/spotifyio/src/spclient.rs @@ -5,7 +5,7 @@ use bytes::Bytes; use data_encoding::{HEXLOWER_PERMISSIVE, HEXUPPER_PERMISSIVE}; use parking_lot::Mutex; use protobuf::{Enum, EnumOrUnknown, Message}; -use reqwest::{header, Method, Response}; +use reqwest::{header, Method, Response, StatusCode}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha1::{Digest, Sha1}; use spotifyio_model::{ @@ -17,7 +17,7 @@ use spotifyio_model::{ SimplifiedEpisode, SimplifiedPlaylist, SimplifiedShow, SimplifiedShows, SimplifiedTrack, }; use time::{Duration, OffsetDateTime}; -use tracing::{debug, info, trace, warn}; +use tracing::{debug, error, info, trace, warn}; use spotifyio_protocol::{ canvaz::{entity_canvaz_response::Canvaz, EntityCanvazRequest, EntityCanvazResponse}, @@ -113,6 +113,23 @@ 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, @@ -480,13 +497,8 @@ impl SpClient { #[tracing::instrument("spclient", level = "error", skip_all, fields(usr = self.session().user_id()))] pub async fn _request_generic( &self, - method: Method, - endpoint: &str, - params: Option<&Query<'_>>, - public_api: bool, - content_type: Option, - body: Vec, - ) -> Result { + p: RequestParams<'_>, + ) -> Result, Error> { let mut tries: usize = 0; let mut last_error: Error; let mut retry_after: Option = None; @@ -494,19 +506,19 @@ impl SpClient { loop { tries += 1; - let url = if endpoint.starts_with('/') { - if public_api { - format!("https://api.spotify.com/v1{endpoint}") + let url = if p.endpoint.starts_with('/') { + if p.public_api { + format!("https://api.spotify.com/v1{}", p.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(endpoint); + url.push_str(p.endpoint); url } } else { - endpoint.to_string() + p.endpoint.to_string() }; // Reconnection logic: keep getting (cached) tokens because they might have expired. @@ -515,22 +527,22 @@ impl SpClient { let mut request = self .session() .http_client() - .request(method.clone(), url) + .request(p.method.clone(), url) .bearer_auth(auth_token) .header(header::ACCEPT_LANGUAGE, &self.session().config().language); - if let Some(content_type) = content_type { + if let Some(content_type) = p.content_type { request = request.header(header::ACCEPT, content_type.mime()); } - if !body.is_empty() { - request = request.body(body.clone()); - if let Some(content_type) = content_type { + if !p.body.is_empty() { + request = request.body(p.body.clone()); + if let Some(content_type) = p.content_type { request = request.header(header::CONTENT_TYPE, content_type.mime()); } } - if !public_api { + if !p.public_api { match self.client_token().await { Ok(client_token) => { request = request.header("client-token", client_token); @@ -542,7 +554,11 @@ impl SpClient { } } - if let Some(params) = params { + if let Some(t) = p.if_none_match { + request = request.header(header::IF_NONE_MATCH, t); + } + + if let Some(params) = p.params { request = request.query(params); } @@ -550,24 +566,38 @@ 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("retry-after") + .get(header::RETRY_AFTER) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse().ok()); - 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() + + 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}"); + } } + 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, @@ -585,7 +615,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 => { - tracing::warn!("API error: {last_error} (retrying)"); + 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 @@ -594,16 +624,16 @@ impl SpClient { ErrorKind::ResourceExhausted => { let wait_time = retry_after.unwrap_or(15); if wait_time > 30 { - tracing::error!("API error 429: need to wait {wait_time}s"); + error!("API error 429: need to wait {wait_time}s"); break; } else { - tracing::warn!("API error 429: waiting for {wait_time}s"); + 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 - tracing::error!("API error: {last_error}"); + error!("API error: {last_error}"); break; } } @@ -618,48 +648,48 @@ impl SpClient { message: &impl Message, ) -> Result { let res = self - ._request_generic( + ._request_generic(RequestParams { method, endpoint, - None, - false, - Some(ContentType::Protobuf), - message.write_to_bytes()?, - ) + content_type: Some(ContentType::Protobuf), + body: message.write_to_bytes()?, + ..Default::default() + }) .await?; - Ok(O::parse_from_bytes(&res)?) + Ok(O::parse_from_bytes(&res.data)?) } - pub async fn request_get_pb(&self, endpoint: &str) -> Result { + pub async fn request_get_pb( + &self, + endpoint: &str, + if_none_match: Option<&str>, + ) -> Result, Error> { let res = self - ._request_generic( - Method::GET, + ._request_generic(RequestParams { endpoint, - None, - false, - Some(ContentType::Protobuf), - Vec::new(), - ) + content_type: Some(ContentType::Protobuf), + if_none_match, + ..Default::default() + }) .await?; - Ok(O::parse_from_bytes(&res)?) + Ok(EtagResponse { + data: O::parse_from_bytes(&res.data)?, + etag: res.etag, + }) } async fn request_get_json(&self, endpoint: &str) -> Result { let res = self - ._request_generic( - Method::GET, + ._request_generic(RequestParams { endpoint, - None, - false, - Some(ContentType::Protobuf), - Vec::new(), - ) + content_type: Some(ContentType::Json), + ..Default::default() + }) .await?; - // Ok(serde_json::from_slice(&res)?) - match serde_json::from_slice(&res) { + match serde_json::from_slice(&res.data) { Ok(res) => Ok(res), Err(e) => { - debug!("JSON response:\n{}", String::from_utf8_lossy(&res)); + debug!("JSON response:\n{}", String::from_utf8_lossy(&res.data)); Err(e.into()) } } @@ -671,70 +701,107 @@ impl SpClient { params: Option<&Query<'_>>, ) -> Result { let res = self - ._request_generic( - Method::GET, + ._request_generic(RequestParams { endpoint, params, - true, - Some(ContentType::Json), - Vec::new(), - ) + public_api: true, + content_type: Some(ContentType::Json), + ..Default::default() + }) .await?; - Ok(serde_json::from_slice(&res)?) + Ok(serde_json::from_slice(&res.data)?) } async fn get_metadata( &self, item_type: SpotifyItemType, id: &SpotifyId, - ) -> Result { - self.request_get_pb(&format!("/metadata/4/{}/{}", item_type, id.to_base16()?)) + if_none_match: Option<&str>, + ) -> Result, Error> { + debug!("getting metadata for {item_type} {id}"); + self.request_get_pb( + &format!("/metadata/4/{}/{}", item_type, id.to_base16()?), + if_none_match, + ) + .await + } + + pub async fn pb_artist( + &self, + id: &SpotifyId, + if_none_match: Option<&str>, + ) -> Result, Error> { + self.get_metadata(SpotifyItemType::Artist, 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_album(&self, id: &SpotifyId) -> Result { - self.get_metadata(SpotifyItemType::Album, id).await - } - - pub async fn pb_track(&self, id: &SpotifyId) -> Result { - self.get_metadata(SpotifyItemType::Track, id).await - } - - pub async fn pb_show(&self, id: &SpotifyId) -> Result { - self.get_metadata(SpotifyItemType::Show, id).await - } - - 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())) + pub async fn pb_album( + &self, + id: &SpotifyId, + if_none_match: Option<&str>, + ) -> Result, Error> { + self.get_metadata(SpotifyItemType::Album, id, if_none_match) .await } + pub async fn pb_track( + &self, + id: &SpotifyId, + if_none_match: Option<&str>, + ) -> Result, Error> { + self.get_metadata(SpotifyItemType::Track, id, if_none_match) + .await + } + + pub async fn pb_show( + &self, + id: &SpotifyId, + if_none_match: Option<&str>, + ) -> Result, Error> { + self.get_metadata(SpotifyItemType::Show, id, if_none_match) + .await + } + + pub async fn pb_episode( + &self, + id: &SpotifyId, + if_none_match: Option<&str>, + ) -> Result, Error> { + self.get_metadata(SpotifyItemType::Episode, id, if_none_match) + .await + } + + pub async fn pb_playlist( + &self, + id: &SpotifyId, + if_none_match: Option<&str>, + ) -> Result, Error> { + debug!("getting metadata for playlist {id}"); + self.request_get_pb( + &format!("/playlist/v2/playlist/{}", id.to_base62()), + if_none_match, + ) + .await + } + pub async fn pb_playlist_diff( &self, id: &SpotifyId, revision: &[u8], ) -> Result { + debug!("getting diff for playlist {id}"); let endpoint = format!( "/playlist/v2/playlist/{}/diff?revision={},{}", id.to_base62(), BigEndian::read_u32(&revision[0..4]), HEXLOWER_PERMISSIVE.encode(&revision[4..]), ); - - self.request_get_pb(&endpoint).await + Ok(self.request_get_pb(&endpoint, None).await?.data) } - async fn pb_extended_metadata<'a, O: Message>( + async fn pb_extended_metadata( &self, - ids: impl IntoIterator + Send + 'a, + ids: impl IntoIterator + Send, item_type: SpotifyItemType, ) -> Result, Error> { let kind = match item_type { @@ -762,6 +829,11 @@ impl SpClient { r }) .collect(); + debug!( + "getting metadata for {} {}s", + request.entity_request.len(), + item_type + ); let resp = self .request_pb::( @@ -774,47 +846,55 @@ impl SpClient { resp.extended_metadata .into_iter() .flat_map(|x| { - 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))) + 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) + .and_then(|parsed| { + Ok((SpotifyId::from_uri(&itm.entity_uri)?, parsed)) + }), + ) + } else { + None + } }) }) .collect::, _>>() } - pub async fn pb_artists<'a>( + pub async fn pb_artists( &self, - ids: impl IntoIterator + Send + 'a, + ids: impl IntoIterator + Send, ) -> Result, Error> { self.pb_extended_metadata(ids, SpotifyItemType::Artist) .await } - pub async fn pb_albums<'a>( + pub async fn pb_albums( &self, - ids: impl IntoIterator + Send + 'a, + ids: impl IntoIterator + Send, ) -> Result, Error> { self.pb_extended_metadata(ids, SpotifyItemType::Album).await } - pub async fn pb_tracks<'a>( + pub async fn pb_tracks( &self, - ids: impl IntoIterator + Send + 'a, + ids: impl IntoIterator + Send, ) -> Result, Error> { self.pb_extended_metadata(ids, SpotifyItemType::Track).await } - pub async fn pb_shows<'a>( + pub async fn pb_shows( &self, - ids: impl IntoIterator + Send + 'a, + ids: impl IntoIterator + Send, ) -> Result, Error> { self.pb_extended_metadata(ids, SpotifyItemType::Show).await } - pub async fn pb_episodes<'a>( + pub async fn pb_episodes( &self, - ids: impl IntoIterator + Send + 'a, + ids: impl IntoIterator + Send, ) -> Result, Error> { self.pb_extended_metadata(ids, SpotifyItemType::Episode) .await @@ -824,11 +904,12 @@ impl SpClient { &self, file_id: &FileId, ) -> Result { + debug!("getting audio storage for {file_id}"); let endpoint = format!( "/storage-resolve/files/audio/interactive/{}", file_id.to_base16()? ); - self.request_get_pb(&endpoint).await + Ok(self.request_get_pb(&endpoint, None).await?.data) } pub async fn get_obfuscated_key( @@ -837,6 +918,8 @@ 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()); @@ -862,6 +945,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 resp = self @@ -875,6 +959,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 separator = match url.find('?') { Some(_) => "&", @@ -894,6 +979,7 @@ 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.to_base16()? @@ -910,15 +996,14 @@ impl SpClient { } pub async fn get_widevine_certificate(&self) -> Result { - self._request_generic( - Method::GET, - "/widevine-license/v1/application-certificate", - None, - false, - None, - Vec::new(), - ) - .await + debug!("getting widevine certificate"); + Ok(self + ._request_generic(RequestParams { + endpoint: "/widevine-license/v1/application-certificate", + ..Default::default() + }) + .await? + .data) } pub async fn get_widevine_license( @@ -926,15 +1011,24 @@ 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"); - self._request_generic(Method::POST, &url, None, false, None, challenge) - .await + Ok(self + ._request_generic(RequestParams { + method: Method::POST, + endpoint: &url, + body: challenge, + ..Default::default() + }) + .await? + .data) } /// Get information about a track's artists, writers and producers pub async fn get_track_credits(&self, track_id: &SpotifyId) -> Result { + debug!("getting track credits for {track_id}"); self.request_get_json(&format!( "/track-credits-view/v0/experimental/{}/credits", track_id.to_base62() @@ -944,17 +1038,18 @@ impl SpClient { pub async fn pb_canvases( &self, - ids: &[&SpotifyId], + ids: impl IntoIterator + Send, ) -> Result, Error> { let mut request = EntityCanvazRequest::new(); request.entities = ids - .iter() + .into_iter() .map(|id| { let mut entity = spotifyio_protocol::canvaz::entity_canvaz_request::Entity::new(); 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) @@ -967,6 +1062,7 @@ impl SpClient { } pub async fn pb_canvas(&self, id: &SpotifyId) -> Result { + debug!("getting canvas for {id}"); let mut request = EntityCanvazRequest::new(); let mut entity = spotifyio_protocol::canvaz::entity_canvaz_request::Entity::new(); entity.entity_uri = id.to_uri(); @@ -982,6 +1078,7 @@ impl SpClient { } pub async fn get_lyrics(&self, id: &SpotifyId) -> Result { + debug!("getting lyrics for {id}"); let res = self .request_get_json::(&format!("/color-lyrics/v2/track/{}", id.to_base62())) .await?; @@ -989,6 +1086,7 @@ impl SpClient { } pub async fn gql_artist_overview(&self, id: &SpotifyId) -> Result { + debug!("getting artist overview for {id}"); 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.to_base62() @@ -1000,6 +1098,7 @@ impl SpClient { } pub async fn gql_artist_concerts(&self, id: &SpotifyId) -> Result, Error> { + debug!("getting artist concerts for {id}"); 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.to_base62() @@ -1011,6 +1110,7 @@ impl SpClient { } pub async fn gql_concert(&self, id: &SpotifyId) -> Result { + debug!("getting concert {id}"); 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", @@ -1027,6 +1127,7 @@ impl SpClient { } pub async fn gql_prerelease(&self, id: &SpotifyId) -> Result { + debug!("getting prerelease {id}"); 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.to_base62() @@ -1050,6 +1151,7 @@ impl SpClient { 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"), @@ -1070,6 +1172,7 @@ impl SpClient { 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.to_base62() @@ -1083,10 +1186,12 @@ impl SpClient { offset: usize, limit: usize, ) -> 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.to_base62())).await } pub async fn get_user_followers(&self, id: &SpotifyId) -> Result, Error> { + debug!("getting user followers {id}"); let res = self .request_get_json::(&format!( "/user-profile-view/v3/profile/{}/followers?market=from_token", @@ -1097,6 +1202,7 @@ impl SpClient { } pub async fn get_user_following(&self, id: &SpotifyId) -> Result, Error> { + debug!("getting user following {id}"); let res = self .request_get_json::(&format!( "/user-profile-view/v3/profile/{}/following?market=from_token", @@ -1119,6 +1225,7 @@ impl SpClient { id: &SpotifyId, market: Option, ) -> Result { + debug!("getting track {id} (web)"); let params = build_map([("market", market.map(Into::into))]); let url = format!("/tracks/{}", id.to_base62()); @@ -1132,12 +1239,13 @@ 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<'a>( + pub async fn web_tracks( &self, - track_ids: impl IntoIterator + Send, + track_ids: impl IntoIterator + Send, market: Option, ) -> Result, Error> { - let ids = join_ids(track_ids)?; + let ids = join_ids(track_ids); + debug!("getting tracks: {ids} (web)"); let params = build_map([("market", market.map(Into::into))]); let url = format!("/tracks/?ids={ids}"); @@ -1152,6 +1260,7 @@ impl SpClient { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artist) pub async fn web_artist(&self, artist_id: &SpotifyId) -> Result { + debug!("getting artist {artist_id} (web)"); let url = format!("/artists/{}", artist_id.to_base62()); self.public_get(&url, None).await } @@ -1162,11 +1271,12 @@ 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<'a>( + pub async fn web_artists( &self, - artist_ids: impl IntoIterator + Send + 'a, + artist_ids: impl IntoIterator + Send, ) -> Result, Error> { - let ids = join_ids(artist_ids)?; + let ids = join_ids(artist_ids); + debug!("getting artists: {ids} (web)"); let url = format!("/artists/?ids={ids}"); let result = self.public_get::(&url, None).await?; Ok(result.artists) @@ -1190,7 +1300,7 @@ impl SpClient { artist_id: &'a SpotifyId, include_groups: impl IntoIterator + Send + Copy + 'a, market: Option, - ) -> Paginator<'_, Result> { + ) -> Paginator<'a, Result> { paginate_with_ctx( (self, artist_id), move |(slf, artist_id), limit, offset| { @@ -1207,14 +1317,15 @@ impl SpClient { } /// The manually paginated version of [`Self::artist_albums`]. - pub async fn web_artist_albums_manual<'a>( + pub async fn web_artist_albums_manual( &self, - artist_id: &'a SpotifyId, - include_groups: impl IntoIterator + Send + 'a, + artist_id: &SpotifyId, + include_groups: impl IntoIterator + Send, 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 @@ -1251,6 +1362,7 @@ impl SpClient { 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.to_base62()); @@ -1270,6 +1382,7 @@ impl SpClient { &self, artist_id: &SpotifyId, ) -> Result, Error> { + debug!("getting related artists of {artist_id} (web)"); let url = format!("/artists/{}/related-artists", artist_id.to_base62()); let result = self.public_get::(&url, None).await?; Ok(result.artists) @@ -1286,6 +1399,7 @@ impl SpClient { 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.to_base62()); @@ -1298,14 +1412,15 @@ 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<'a>( + pub async fn web_albums( &self, - album_ids: impl IntoIterator + Send + 'a, + album_ids: impl IntoIterator + Send, 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) @@ -1343,6 +1458,7 @@ 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([ @@ -1389,6 +1505,7 @@ 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 @@ -1422,7 +1539,7 @@ impl SpClient { &'a self, album_id: &'a SpotifyId, market: Option, - ) -> Paginator<'_, Result> { + ) -> Paginator<'a, Result> { paginate_with_ctx( (self, album_id), move |(slf, album_id), limit, offset| { @@ -1433,13 +1550,14 @@ impl SpClient { } /// The manually paginated version of [`Self::album_track`]. - pub async fn web_album_track_manual<'a>( + pub async fn web_album_track_manual( &self, - album_id: &'a SpotifyId, + album_id: &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([ @@ -1459,6 +1577,7 @@ impl SpClient { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-users-profile) pub async fn web_user(&self, user_id: &SpotifyId) -> Result { + debug!("getting user {user_id} (web)"); let url = format!("/users/{}", user_id.to_base62()); self.public_get(&url, None).await } @@ -1476,6 +1595,7 @@ impl SpClient { 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.to_base62()); @@ -1496,6 +1616,7 @@ impl SpClient { 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 { @@ -1517,15 +1638,17 @@ 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<'a>( + pub async fn web_playlist_check_follow( &self, - playlist_id: &'a SpotifyId, - user_ids: impl IntoIterator + Send, + playlist_id: &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.to_base62(), - join_ids(user_ids)?, + ids, ); self.public_get(&url, None).await } @@ -1544,6 +1667,7 @@ impl SpClient { id: &SpotifyId, market: Option, ) -> Result { + debug!("getting show {id} (web)"); let params = build_map([("market", market.map(Into::into))]); let url = format!("/shows/{}", id.to_base62()); @@ -1558,12 +1682,13 @@ 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<'a>( + pub async fn web_get_several_shows( &self, - ids: impl IntoIterator + Send + 'a, + ids: impl IntoIterator + Send, market: Option, ) -> Result, Error> { - let ids = join_ids(ids)?; + let ids = join_ids(ids); + debug!("getting shows: {ids} (web)"); let params = build_map([("ids", Some(&ids)), ("market", market.map(Into::into))]); let result = self @@ -1591,7 +1716,7 @@ impl SpClient { &'a self, id: &'a SpotifyId, market: Option, - ) -> Paginator<'_, Result> { + ) -> Paginator<'a, Result> { paginate_with_ctx( (self, id), move |(slf, id), limit, offset| { @@ -1609,6 +1734,7 @@ impl SpClient { 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([ @@ -1635,6 +1761,7 @@ impl SpClient { id: &SpotifyId, market: Option, ) -> Result { + debug!("getting episode {id} (web)"); let params = build_map([("market", market.map(Into::into))]); let url = format!("/episodes/{}", id.to_base62()); self.public_get(&url, Some(¶ms)).await @@ -1647,12 +1774,13 @@ 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<'a>( + pub async fn web_get_several_episodes( &self, - ids: impl IntoIterator + Send + 'a, + ids: impl IntoIterator + Send, market: Option, ) -> Result, Error> { - let ids = join_ids(ids)?; + let ids = join_ids(ids); + debug!("getting episodes: {ids} (web)"); let params = build_map([("ids", Some(&ids)), ("market", market.map(Into::into))]); let result = self @@ -1668,6 +1796,7 @@ impl SpClient { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-audio-features) pub async fn web_track_features(&self, track_id: &SpotifyId) -> Result { + debug!("getting track features for {track_id} (web)"); let url = format!("/audio-features/{}", track_id.to_base62()); self.public_get(&url, None).await } @@ -1678,11 +1807,13 @@ 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<'a>( + pub async fn web_tracks_features( &self, - track_ids: impl IntoIterator + Send + 'a, + track_ids: impl IntoIterator + Send, ) -> Result, Error> { - let url = format!("/audio-features/?ids={}", join_ids(track_ids)?); + let ids = join_ids(track_ids); + debug!("getting track features for {ids} (web)"); + let url = format!("/audio-features/?ids={ids}"); let result = self .public_get::>(&url, None) @@ -1701,6 +1832,7 @@ impl SpClient { /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-audio-analysis) pub async fn web_track_analysis(&self, track_id: &SpotifyId) -> Result { + debug!("getting audio analysis for {track_id} (web)"); let url = format!("/audio-analysis/{}", track_id.to_base62()); self.public_get(&url, None).await } @@ -1724,7 +1856,7 @@ impl SpClient { &'a self, locale: Option<&'a str>, country: Option, - ) -> Paginator<'_, Result> { + ) -> Paginator<'a, Result> { paginate( move |limit, offset| { self.web_categories_manual(locale, country, Some(limit), Some(offset)) @@ -1741,6 +1873,7 @@ 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([ @@ -1773,7 +1906,7 @@ impl SpClient { &'a self, category_id: &'a str, country: Option, - ) -> Paginator<'_, Result> { + ) -> Paginator<'a, Result> { paginate( move |limit, offset| { self.web_category_playlists_manual(category_id, country, Some(limit), Some(offset)) @@ -1790,6 +1923,7 @@ 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([ @@ -1809,6 +1943,7 @@ 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 } @@ -1837,6 +1972,7 @@ 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| { @@ -1885,6 +2021,7 @@ 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([ @@ -1916,18 +2053,18 @@ impl SpClient { /// targeting on results. /// /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-recommendations) - pub async fn web_recommendations<'a>( + pub async fn web_recommendations( &self, - attributes: impl IntoIterator + Send + 'a, - seed_artists: Option + Send + 'a>, - seed_genres: Option + Send + 'a>, - seed_tracks: Option + Send + 'a>, + attributes: impl IntoIterator + Send, + seed_artists: Option + Send>, + seed_genres: Option + Send>, + seed_tracks: Option + Send>, market: Option, limit: Option, ) -> Result { - let seed_artists = seed_artists.and_then(|a| join_ids(a).ok()); + let seed_artists = seed_artists.map(|a| join_ids(a)); let seed_genres = seed_genres.map(|x| x.into_iter().collect::>().join(",")); - let seed_tracks = seed_tracks.and_then(|t| join_ids(t).ok()); + let seed_tracks = seed_tracks.map(|t| join_ids(t)); let limit = limit.map(|x| x.to_string()); let mut params = build_map([ ("seed_artists", seed_artists.as_deref()), @@ -1936,6 +2073,7 @@ 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 @@ -1971,7 +2109,7 @@ impl SpClient { playlist_id: &'a SpotifyId, fields: Option<&'a str>, market: Option, - ) -> Paginator<'_, Result> { + ) -> Paginator<'a, Result> { paginate_with_ctx( (self, playlist_id, fields), move |(slf, playlist_id, fields), limit, offset| { @@ -1996,6 +2134,7 @@ impl SpClient { 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([ @@ -2023,7 +2162,7 @@ impl SpClient { pub fn web_user_playlists<'a>( &'a self, user_id: &'a SpotifyId, - ) -> Paginator<'_, Result> { + ) -> Paginator<'a, Result> { paginate_with_ctx( (self, user_id), move |(slf, user_id), limit, offset| { @@ -2040,6 +2179,7 @@ impl SpClient { 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())]); @@ -2063,12 +2203,11 @@ fn build_map<'key, 'value, const N: usize>( map } -fn join_ids<'a>(ids: impl IntoIterator) -> Result { - Ok(ids - .into_iter() +fn join_ids<'a>(ids: impl IntoIterator) -> String { + ids.into_iter() .map(SpotifyId::to_base62) .collect::>() - .join(",")) + .join(",") } #[cfg(test)] @@ -2089,12 +2228,31 @@ mod tests { let s = conn().await; let artist = s .spclient() - .pb_artist(&SpotifyId::from_base62("1EfwyuCzDQpCslZc8C9gkG").unwrap()) + .pb_artist( + &SpotifyId::from_base62("1EfwyuCzDQpCslZc8C9gkG").unwrap(), + // Some("\"MC-EvTsdg==\""), + None, + ) .await .unwrap(); dbg!(&artist); } + #[tokio::test] + async fn get_artists() { + let s = conn().await; + let artists = s + .spclient() + .pb_artists([ + &SpotifyId::from_base62("1EfwyuCzDQpCslZc8C9gkG").unwrap(), + &SpotifyId::from_base62("5RJFJWYgtgWktosLrUDzxx").unwrap(), // does not exist + &SpotifyId::from_base62("2NpPlwwDVYR5dIj0F31EcC").unwrap(), + ]) + .await + .unwrap(); + dbg!(&artists); + } + #[tokio::test] async fn get_artist_overview() { let s = conn().await; From b12543dbb88de34149d8983edb56d606d0b84985 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 29 Nov 2024 19:31:12 +0100 Subject: [PATCH 04/10] chore: bump downloader version --- crates/downloader/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/downloader/Cargo.toml b/crates/downloader/Cargo.toml index 3da2bfb..3df5530 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.1.0" +version = "0.2.0" edition.workspace = true authors.workspace = true license.workspace = true From ad8817c8c617727fb088f522163e4efb82dd118e Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 29 Nov 2024 19:38:26 +0100 Subject: [PATCH 05/10] fix: retry on GatewayTimeout error --- crates/spotifyio/src/error.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/spotifyio/src/error.rs b/crates/spotifyio/src/error.rs index c46643a..a343dc5 100644 --- a/crates/spotifyio/src/error.rs +++ b/crates/spotifyio/src/error.rs @@ -297,6 +297,7 @@ 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() { From 7c4c2d20583f1045dfabc7e98c03c6faed6b04e3 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Thu, 5 Dec 2024 12:17:39 +0100 Subject: [PATCH 06/10] fix: several fixes to the Spotify client --- crates/cli/src/main.rs | 104 ++++++++++++++++++++++--------- crates/downloader/src/main.rs | 2 +- crates/model/Cargo.toml | 1 + crates/model/src/spotify_id.rs | 86 +++++++++++++++++-------- crates/spotifyio/src/model.rs | 70 ++++++++++++++++++--- crates/spotifyio/src/spclient.rs | 69 ++++++++++++-------- 6 files changed, 241 insertions(+), 91 deletions(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index a706351..03b5ff0 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -17,6 +17,8 @@ use widevine::{Cdm, Device, Pssh}; struct Cli { #[clap(subcommand)] command: Commands, + #[clap(long, default_value = "en")] + lang: String, } #[derive(Subcommand)] @@ -32,6 +34,8 @@ enum Commands { Accounts, Track { id: String, + #[clap(long)] + key: bool, }, Album { id: String, @@ -39,6 +43,12 @@ enum Commands { Artist { id: String, }, + Playlist { + id: String, + }, + User { + id: String, + }, Prerelease { id: String, }, @@ -67,11 +77,15 @@ async fn main() -> Result<(), Error> { let app_cache = ApplicationCache::new(path); let cli = Cli::parse(); + let scfg = SessionConfig { + language: cli.lang, + ..Default::default() + }; 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(SessionConfig::default(), cache); + let session = Session::new(scfg, cache); session .connect(AuthCredentials { user_id: None, @@ -101,10 +115,10 @@ async fn main() -> Result<(), Error> { ); } } - Commands::Track { id } => { + Commands::Track { id, key } => { let pool = SpotifyIoPool::from_app_cache( app_cache, - &SessionConfig::default(), + &scfg, &PoolConfig { max_sessions: 1, ..Default::default() @@ -120,31 +134,33 @@ async fn main() -> Result<(), Error> { protobuf_json_mapping::print_to_string(&track).unwrap() ); - let spotify_id = SpotifyId::from_raw(track.gid()).unwrap(); - println!("Track id: {}", spotify_id.to_base62()); + if key { + 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()); + 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)); } - - 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, - &SessionConfig::default(), + &scfg, &PoolConfig { max_sessions: 1, ..Default::default() @@ -163,7 +179,7 @@ async fn main() -> Result<(), Error> { Commands::Artist { id } => { let pool = SpotifyIoPool::from_app_cache( app_cache, - &SessionConfig::default(), + &scfg, &PoolConfig { max_sessions: 1, ..Default::default() @@ -178,10 +194,40 @@ async fn main() -> Result<(), Error> { 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 = 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 = SpotifyId::from_textual(&id)?; + let user = pool.spclient()?.get_user_profile(&id, 20).await?; + println!("{}", serde_json::to_string(&user).unwrap()); + } Commands::Prerelease { id } => { let pool = SpotifyIoPool::from_app_cache( app_cache, - &SessionConfig::default(), + &scfg, &PoolConfig { max_sessions: 1, ..Default::default() @@ -192,11 +238,7 @@ async fn main() -> Result<(), Error> { println!("{}", serde_json::to_string(&prerelease).unwrap()); } Commands::Ratelimit { ids } => { - let pool = SpotifyIoPool::from_app_cache( - app_cache, - &SessionConfig::default(), - &PoolConfig::default(), - )?; + let pool = SpotifyIoPool::from_app_cache(app_cache, &scfg, &PoolConfig::default())?; let ids = std::fs::read_to_string(ids)? .lines() .filter_map(|line| parse_idstr(line).ok()) @@ -251,7 +293,7 @@ async fn main() -> Result<(), Error> { Commands::Widevine { id } => { let pool = SpotifyIoPool::from_app_cache( app_cache, - &SessionConfig::default(), + &scfg, &PoolConfig { max_sessions: 1, ..Default::default() @@ -293,7 +335,7 @@ async fn main() -> Result<(), Error> { Commands::Upkeep => { let pool = SpotifyIoPool::from_app_cache( app_cache, - &SessionConfig::default(), + &scfg, &PoolConfig { max_sessions: 1, ..Default::default() diff --git a/crates/downloader/src/main.rs b/crates/downloader/src/main.rs index 3334af4..64e4ba6 100644 --- a/crates/downloader/src/main.rs +++ b/crates/downloader/src/main.rs @@ -206,7 +206,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { let artists = if let Ok(parsed) = parsed { let got_artists = dl.session()?.spclient().pb_artists(parsed.iter()).await?; got_artists - .values() + .iter() .map(|a| { Ok(spotifyio_downloader::model::ArtistItem { id: SpotifyId::from_raw(a.gid())?.to_base62().into_owned(), diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index 7e10d4d..fce4730 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -20,3 +20,4 @@ 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" diff --git a/crates/model/src/spotify_id.rs b/crates/model/src/spotify_id.rs index f070ea2..e73914b 100644 --- a/crates/model/src/spotify_id.rs +++ b/crates/model/src/spotify_id.rs @@ -91,12 +91,6 @@ 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, @@ -153,7 +147,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() != 22 { + if src.len() != Self::SIZE_BASE62 { return Err(IdError::InvalidId); } let mut dst: u128 = 0; @@ -176,6 +170,29 @@ 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`. @@ -212,29 +229,39 @@ impl SpotifyId { let (tpe, id) = rest.split_once(sep).ok_or(IdError::InvalidFormat)?; let item_type = SpotifyItemType::from(tpe); - if item_type.uses_textual_id() { - if id.is_empty() { - return Err(IdError::InvalidId); + 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, + }) } - Ok(Self { + SpotifyItemType::Local => Ok(Self { id: SpotifyIdInner::Textual(id.to_owned()), item_type, - }) - } else { - if id.len() != Self::SIZE_BASE62 { - return Err(IdError::InvalidId); - } - Ok(Self { + }), + _ => 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_base62(id_or_uri), + Err(IdError::InvalidPrefix) => Self::from_textual(id_or_uri), Err(error) => Err(error), } } @@ -250,8 +277,11 @@ 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]; @@ -292,7 +322,13 @@ impl SpotifyId { String::from_utf8(dst.to_vec()).unwrap().into() } - SpotifyIdInner::Textual(id) => id.into(), + SpotifyIdInner::Textual(id) => { + if uri { + urlencoding::encode(id) + } else { + id.into() + } + } } } @@ -323,7 +359,7 @@ impl SpotifyId { dst.push_str("spotify:"); dst.push_str(item_type); dst.push(':'); - dst.push_str(&self.to_base62()); + dst.push_str(&self._to_base62(true)); dst } @@ -335,7 +371,7 @@ impl SpotifyId { dst.push_str("spotify:"); dst.push_str(item_type); dst.push(':'); - dst.push_str(&self.to_base62()); + dst.push_str(&self._to_base62(true)); Ok(dst) } @@ -440,7 +476,7 @@ pub mod ser { where E: serde::de::Error, { - SpotifyId::from_base62(v).map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`"))) + SpotifyId::from_textual(v).map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`"))) } } @@ -471,7 +507,7 @@ pub mod ser { where E: serde::de::Error, { - SpotifyId::from_base62(v) + SpotifyId::from_textual(v) .map(Some) .map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`"))) } diff --git a/crates/spotifyio/src/model.rs b/crates/spotifyio/src/model.rs index 91c291d..4dd00e8 100644 --- a/crates/spotifyio/src/model.rs +++ b/crates/spotifyio/src/model.rs @@ -29,7 +29,7 @@ pub struct GqlWrap { #[serde(tag = "__typename")] #[allow(clippy::large_enum_variant)] pub enum PlaylistOption { - Playlist(PlaylistItem), + Playlist(GqlPlaylistItem), #[serde(alias = "GenericError")] NotFound, } @@ -120,7 +120,10 @@ pub struct ArtistProfile { pub verified: bool, #[serde(default)] pub biography: Biography, - pub playlists_v2: Option>>, + #[serde(default)] + pub external_links: GqlPagination, + #[serde(default)] + pub playlists_v2: GqlPagination>, } #[derive(Default, Debug, Clone, Serialize, Deserialize)] @@ -129,6 +132,13 @@ 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] @@ -258,7 +268,7 @@ pub struct Name { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[non_exhaustive] -pub struct PlaylistItem { +pub struct GqlPlaylistItem { pub uri: SpotifyId, pub name: String, pub images: GqlPagination, @@ -271,7 +281,7 @@ pub struct PlaylistItem { pub struct UserItem { pub uri: Option, #[serde(alias = "displayName")] - pub name: String, + pub name: Option, pub avatar: Option, } @@ -318,7 +328,7 @@ pub struct PrereleaseContent { /// URI of the to-be-released album pub uri: Option, pub name: String, - pub cover_art: Image, + pub cover_art: Option, pub artists: Option>>, pub tracks: Option>, pub copyright: Option, @@ -398,7 +408,7 @@ pub struct AlbumItem { pub uri: SpotifyId, pub name: String, pub date: Option, - pub cover_art: Image, + pub cover_art: Option, pub artists: Option>, } @@ -491,18 +501,26 @@ 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: SpotifyId, pub name: String, pub owner_name: String, - pub owner_uri: String, + pub owner_uri: SpotifyId, /// UID-based image id /// /// - `spotify:image:ab67706c0000da8474fffd106bb7f5be3ba4b758` /// - `spotify:mosaic:ab67616d00001e021c04efd2804b16cf689de7f0:ab67616d00001e0269f63a842ea91ca7c522593a:ab67616d00001e0270dbc9f47669d120ad874ec1:ab67616d00001e027d384516b23347e92a587ed1` - pub image_url: String, + pub image_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -564,3 +582,39 @@ pub struct CreditedArtist { 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/spclient.rs b/crates/spotifyio/src/spclient.rs index 92a06ee..f5592e9 100644 --- a/crates/spotifyio/src/spclient.rs +++ b/crates/spotifyio/src/spclient.rs @@ -41,8 +41,9 @@ use crate::{ error::ErrorKind, model::{ ArtistGql, ArtistGqlWrap, Concert, ConcertGql, ConcertGqlWrap, ConcertOption, FollowerItem, - GqlSearchResult, GqlWrap, Lyrics, LyricsWrap, PrereleaseItem, PrereleaseLookup, - SearchItemType, SearchResultWrap, Seektable, TrackCredits, UserProfile, UserProfilesWrap, + GqlPlaylistItem, GqlSearchResult, GqlWrap, Lyrics, LyricsWrap, PlaylistWrap, + PrereleaseItem, PrereleaseLookup, SearchItemType, SearchResultWrap, Seektable, + TrackCredits, UserPlaylists, UserProfile, UserProfilesWrap, }, pagination::{paginate, paginate_with_ctx, Paginator}, session::SessionWeak, @@ -803,7 +804,7 @@ impl SpClient { &self, ids: impl IntoIterator + Send, item_type: SpotifyItemType, - ) -> Result, Error> { + ) -> Result, Error> { let kind = match item_type { SpotifyItemType::Album => ExtensionKind::ALBUM_V4, SpotifyItemType::Artist => ExtensionKind::ARTIST_V4, @@ -848,25 +849,19 @@ impl SpClient { .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) - .and_then(|parsed| { - Ok((SpotifyId::from_uri(&itm.entity_uri)?, parsed)) - }), - ) + Some(O::parse_from_bytes(&itm.extension_data.value).map_err(Error::from)) } else { None } }) }) - .collect::, _>>() + .collect() } pub async fn pb_artists( &self, ids: impl IntoIterator + Send, - ) -> Result, Error> { + ) -> Result, Error> { self.pb_extended_metadata(ids, SpotifyItemType::Artist) .await } @@ -874,28 +869,28 @@ impl SpClient { pub async fn pb_albums( &self, ids: impl IntoIterator + Send, - ) -> Result, Error> { + ) -> Result, Error> { self.pb_extended_metadata(ids, SpotifyItemType::Album).await } pub async fn pb_tracks( &self, ids: impl IntoIterator + Send, - ) -> Result, Error> { + ) -> Result, Error> { self.pb_extended_metadata(ids, SpotifyItemType::Track).await } pub async fn pb_shows( &self, ids: impl IntoIterator + Send, - ) -> Result, Error> { + ) -> Result, Error> { self.pb_extended_metadata(ids, SpotifyItemType::Show).await } pub async fn pb_episodes( &self, ids: impl IntoIterator + Send, - ) -> Result, Error> { + ) -> Result, Error> { self.pb_extended_metadata(ids, SpotifyItemType::Episode) .await } @@ -1074,7 +1069,7 @@ impl SpClient { resp.canvases .into_iter() .next() - .ok_or(Error::not_found("no canvas found")) + .ok_or_else(|| Error::not_found(format!("canvas for {id}"))) } pub async fn get_lyrics(&self, id: &SpotifyId) -> Result { @@ -1147,8 +1142,8 @@ impl SpClient { pub async fn gql_search( &self, query: &str, - offset: usize, - limit: usize, + offset: u32, + limit: u32, typ: Option, ) -> Result { debug!("searching `{query}` (typ={typ:?},gql)"); @@ -1167,10 +1162,21 @@ impl SpClient { Ok(res.data.search_v2) } + pub async fn gql_playlist(&self, id: &SpotifyId) -> 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.to_base62()); + 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: &SpotifyId, - playlist_limit: usize, + playlist_limit: u32, ) -> Result { debug!("getting user profile {id}"); self.request_get_json(&format!( @@ -1183,11 +1189,11 @@ impl SpClient { pub async fn get_user_playlists( &self, id: &SpotifyId, - offset: usize, - limit: usize, - ) -> Result { + 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.to_base62())).await + 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: &SpotifyId) -> Result, Error> { @@ -2297,7 +2303,7 @@ mod tests { .gql_search("this is me", 0, n, Some(SearchItemType::Tracks)) .await .unwrap(); - assert_eq!(search.tracks_v2.items.len(), n); + assert_eq!(search.tracks_v2.items.len(), n as usize); dbg!(&search); } @@ -2310,7 +2316,7 @@ mod tests { .gql_search("test", 0, n, Some(SearchItemType::Users)) .await .unwrap(); - assert_eq!(search.users.items.len(), n); + assert_eq!(search.users.items.len(), n as usize); dbg!(&search); } @@ -2421,4 +2427,15 @@ 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(&SpotifyId::from_base62("1Th7TfDCbL8I9v6nRhWObF").unwrap()) + .await + .unwrap(); + dbg!(&pl); + } } From 2cd8107a110c0b0a99ebdff9e1254c7467a8de3c Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Thu, 5 Dec 2024 12:29:07 +0100 Subject: [PATCH 07/10] fix: cli: make base_dir optional --- crates/downloader/Cargo.toml | 2 +- crates/downloader/src/main.rs | 13 ++++++------- crates/model/src/spotify_id.rs | 7 +++++++ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/downloader/Cargo.toml b/crates/downloader/Cargo.toml index 3df5530..1ea6c34 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.2.0" +version = "0.2.1" edition.workspace = true authors.workspace = true license.workspace = true diff --git a/crates/downloader/src/main.rs b/crates/downloader/src/main.rs index 64e4ba6..ef7db96 100644 --- a/crates/downloader/src/main.rs +++ b/crates/downloader/src/main.rs @@ -51,8 +51,8 @@ struct Cli { #[clap(long, default_value = "spotifyio.json")] cache: PathBuf, /// Path to music library base directory - #[clap(long)] - base_dir: Option, + #[clap(long, default_value = "Downloads")] + base_dir: PathBuf, /// Path to SQLite database #[clap(long, default_value = "tracks.db")] db: PathBuf, @@ -178,9 +178,6 @@ 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()), @@ -189,7 +186,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { cache_path: cli.cache, quota, db_path: cli.db, - base_dir, + base_dir: cli.base_dir, progress: Some(multi), format_m4a: cli.m4a, widevine_device: cli.wvd, @@ -371,7 +368,9 @@ fn parse_quota(s: &str) -> Result { fn parse_url_or_id(s: &str) -> Result { if let Ok(id) = SpotifyId::from_id_or_uri(s) { - return Ok(id); + if !id.is_textual() { + return Ok(id); + } } let url = Url::parse(s).map_err(|_| ())?; diff --git a/crates/model/src/spotify_id.rs b/crates/model/src/spotify_id.rs index e73914b..0f23078 100644 --- a/crates/model/src/spotify_id.rs +++ b/crates/model/src/spotify_id.rs @@ -375,6 +375,13 @@ impl SpotifyId { 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, From c10079f58116fd60cd4e35a09653bf3b0e6467b6 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 6 Dec 2024 02:59:00 +0100 Subject: [PATCH 08/10] refactor: reworked ID model --- crates/cli/src/main.rs | 48 +- crates/downloader/src/lib.rs | 151 +- crates/downloader/src/main.rs | 48 +- crates/downloader/src/model/audio_file.rs | 2 +- crates/downloader/src/model/mod.rs | 17 +- crates/model/Cargo.toml | 1 + crates/model/src/album.rs | 10 +- crates/model/src/artist.rs | 8 +- crates/model/src/audio.rs | 5 +- crates/model/src/context.rs | 4 +- crates/model/src/enums/types.rs | 4 +- crates/model/src/file_id.rs | 11 +- crates/model/src/idtypes.rs | 1292 +++++++++++++++++ crates/model/src/lib.rs | 12 +- crates/model/src/playlist.rs | 8 +- crates/model/src/show.rs | 14 +- crates/model/src/track.rs | 15 +- crates/model/src/user.rs | 8 +- crates/spotifyio/src/audio_key.rs | 14 +- crates/spotifyio/src/cdn_url.rs | 2 +- .../spotifyio/src/{model.rs => gql_model.rs} | 41 +- crates/spotifyio/src/lib.rs | 9 +- crates/spotifyio/src/oauth.rs | 4 +- crates/spotifyio/src/session.rs | 23 +- crates/spotifyio/src/spclient.rs | 346 ++--- 25 files changed, 1670 insertions(+), 427 deletions(-) create mode 100644 crates/model/src/idtypes.rs rename crates/spotifyio/src/{model.rs => gql_model.rs} (95%) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 03b5ff0..0d8384c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -5,8 +5,10 @@ use clap::{Parser, Subcommand}; use futures_util::{stream, StreamExt, TryStreamExt}; use path_macro::path; use spotifyio::{ - pb::AudioFileFormat, ApplicationCache, AuthCredentials, AuthenticationType, CdnUrl, FileId, - IdError, PoolConfig, Session, SessionConfig, SpotifyId, SpotifyIoPool, + model::{FileId, Id, IdConstruct, IdError, PlayableId, TrackId, UserId}, + pb::AudioFileFormat, + ApplicationCache, AuthCredentials, AuthenticationType, CdnUrl, PoolConfig, Session, + SessionConfig, SpotifyIoPool, }; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; @@ -126,7 +128,7 @@ async fn main() -> Result<(), Error> { )?; let spotify_id = parse_idstr(&id).unwrap(); - let track_et = pool.spclient()?.pb_track(&spotify_id, None).await?; + 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!( @@ -135,8 +137,8 @@ async fn main() -> Result<(), Error> { ); if key { - let spotify_id = SpotifyId::from_raw(track.gid()).unwrap(); - println!("Track id: {}", spotify_id.to_base62()); + let spotify_id = TrackId::from_raw(track.gid()).unwrap(); + println!("Track id: {}", spotify_id.id()); for file in &track.file { println!("{} {:?}", FileId::from_raw(file.file_id())?, file.format()); @@ -152,7 +154,7 @@ async fn main() -> Result<(), Error> { let key = pool .audio_key() .await? - .request(&spotify_id, &file_id) + .request(PlayableId::Track(spotify_id), &file_id) .await?; println!("Audio key: {}", data_encoding::HEXLOWER.encode(&key.0)); } @@ -168,7 +170,7 @@ async fn main() -> Result<(), Error> { )?; let id = parse_idstr(&id)?; - let album_et = pool.spclient()?.pb_album(&id, None).await?; + let album_et = pool.spclient()?.pb_album(id, None).await?; tracing::info!("ETag: {}", album_et.etag.unwrap_or_default()); println!( @@ -187,7 +189,7 @@ async fn main() -> Result<(), Error> { )?; let id = parse_idstr(&id)?; - let artist_et = pool.spclient()?.pb_artist(&id, None).await?; + let artist_et = pool.spclient()?.pb_artist(id, None).await?; tracing::info!("ETag: {}", artist_et.etag.unwrap_or_default()); println!( "{}", @@ -206,7 +208,7 @@ async fn main() -> Result<(), Error> { let id = parse_idstr(&id)?; - let playlist = pool.spclient()?.pb_playlist(&id, None).await?; + let playlist = pool.spclient()?.pb_playlist(id, None).await?; tracing::info!("ETag: {}", playlist.etag.unwrap_or_default()); dbg!(&playlist.data); } @@ -220,8 +222,8 @@ async fn main() -> Result<(), Error> { }, )?; - let id = SpotifyId::from_textual(&id)?; - let user = pool.spclient()?.get_user_profile(&id, 20).await?; + let id = UserId::from_id(&id)?; + let user = pool.spclient()?.get_user_profile(id, 20).await?; println!("{}", serde_json::to_string(&user).unwrap()); } Commands::Prerelease { id } => { @@ -234,12 +236,13 @@ async fn main() -> Result<(), Error> { }, )?; let id = parse_idstr(&id)?; - let prerelease = pool.spclient()?.gql_prerelease(&id).await?; + let prerelease = pool.spclient()?.gql_prerelease(id).await?; println!("{}", serde_json::to_string(&prerelease).unwrap()); } Commands::Ratelimit { ids } => { let pool = SpotifyIoPool::from_app_cache(app_cache, &scfg, &PoolConfig::default())?; - let ids = std::fs::read_to_string(ids)? + let txt = std::fs::read_to_string(ids)?; + let ids = txt .lines() .filter_map(|line| parse_idstr(line).ok()) .collect::>(); @@ -251,9 +254,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, @@ -300,7 +303,7 @@ async fn main() -> Result<(), Error> { }, )?; let spid = parse_idstr(&id)?; - let track = pool.spclient()?.pb_track(&spid, None).await?.data; + let track = pool.spclient()?.pb_track(spid, None).await?.data; let mp4_file = track .file .iter() @@ -344,13 +347,16 @@ 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 = parse_idstr::("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(&spotify_id, &file_id).await { + Ok(km) => match km + .request(PlayableId::Track(spotify_id.as_ref()), &file_id) + .await + { Ok(key) => { println!("Audio key: {}", data_encoding::HEXLOWER.encode(&key.0)) } @@ -364,13 +370,13 @@ async fn main() -> Result<(), Error> { Ok(()) } -fn parse_idstr(s: &str) -> Result { +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)?; - SpotifyId::from_raw(&bytes) + T::from_raw(&bytes) } else { - SpotifyId::from_id_or_uri(s) + T::from_id_or_uri(s) } } diff --git a/crates/downloader/src/lib.rs b/crates/downloader/src/lib.rs index 6b033c7..80e3cc5 100644 --- a/crates/downloader/src/lib.rs +++ b/crates/downloader/src/lib.rs @@ -27,9 +27,10 @@ use once_cell::sync::Lazy; use path_macro::path; use serde::Serialize; use spotifyio::{ - pb::AudioFileFormat, AudioKey, CdnUrl, Error as SpotifyError, FileId, IdError, - NormalisationData, NotModifiedRes, PoolConfig, PoolError, Quota, Session, SessionConfig, - SpotifyId, SpotifyIoPool, SpotifyItemType, + 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, }; use spotifyio_protocol::metadata::{Album, Availability, Restriction, Track}; use sqlx::{sqlite::SqliteConnectOptions, ConnectOptions, SqlitePool}; @@ -383,17 +384,17 @@ impl SpotifyDownloader { Ok(cert) } - async fn get_audio_item(&self, spotify_id: &SpotifyId) -> Result { + async fn get_audio_item(&self, spotify_id: PlayableId<'_>) -> Result { let session = self.i.sp.session()?; - match spotify_id.item_type { - SpotifyItemType::Track => { - let track = session.spclient().pb_track(spotify_id, None).await?.data; + match spotify_id { + PlayableId::Track(id) => { + let track = session.spclient().pb_track(id, None).await?.data; map_track(session, track) } - SpotifyItemType::Episode => { - let episode = session.spclient().pb_episode(spotify_id, None).await?.data; + PlayableId::Episode(id) => { + let episode = session.spclient().pb_episode(id, None).await?.data; Ok(AudioItem { - track_id: SpotifyId::from_raw(episode.gid())?.episode(), + track_id: PlayableId::Episode(EpisodeId::from_raw(episode.gid())?), actual_id: None, files: AudioFiles::from(episode.audio.as_slice()), covers: get_covers(episode.cover_image.image.iter()), @@ -415,13 +416,12 @@ impl SpotifyDownloader { }), }) } - _ => Err(MetadataError::NonPlayable.into()), } } async fn load_track( &self, - spotify_id: &SpotifyId, + spotify_id: PlayableId<'_>, ) -> Result<(AudioItem, FileId, AudioFileFormat), Error> { let audio_item = self.get_audio_item(spotify_id).await?; let audio_item = self.find_available_alternative(audio_item).await?; @@ -469,7 +469,7 @@ impl SpotifyDownloader { async fn get_file_key_clearkey( &self, - spotify_id: &SpotifyId, + spotify_id: PlayableId<'_>, file_id: FileId, ) -> Result { let key = self @@ -510,7 +510,7 @@ impl SpotifyDownloader { async fn get_file_key( &self, - spotify_id: &SpotifyId, + spotify_id: PlayableId<'_>, file_id: FileId, format: AudioFileFormat, ) -> Result { @@ -525,8 +525,8 @@ impl SpotifyDownloader { } } - async fn get_genre(&self, artist_id: &SpotifyId) -> Result, Error> { - let aid_str = artist_id.to_base62().into_owned(); + async fn get_genre(&self, artist_id: ArtistId<'_>) -> Result, Error> { + let aid_str = artist_id.id().to_owned(); { let gc = GENRE_CACHE.read().unwrap(); @@ -556,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: &SpotifyId) -> Result { + async fn get_n_discs(&self, album_id: AlbumId<'_>) -> Result { let aid_str = album_id.to_string(); { @@ -582,8 +582,8 @@ impl SpotifyDownloader { Ok(final_res) } - async fn mark_track_unavailable(&self, track_id: &SpotifyId) -> Result<(), Error> { - let id = track_id.to_base62(); + async fn mark_track_unavailable(&self, track_id: TrackId<'_>) -> Result<(), Error> { + let id = track_id.id(); sqlx::query!("update tracks set not_available=true where id=?", id) .execute(&self.i.pool) .await?; @@ -600,7 +600,7 @@ impl SpotifyDownloader { for alt in audio_item.alternatives { if alt.availability.is_ok() { - audio_item.actual_id = Some(alt.track_id); + audio_item.actual_id = Some(PlayableId::Track(alt.track_id)); audio_item.alternatives = Vec::new(); audio_item.availability = alt.availability; audio_item.files = alt.files; @@ -689,10 +689,11 @@ impl SpotifyDownloader { #[tracing::instrument(level = "error", skip(self, force))] pub async fn download_track( &self, - track_id: &SpotifyId, + track_id: TrackId<'_>, force: bool, ) -> Result, Error> { - let input_id_str = track_id.to_base62(); + let pid = PlayableId::Track(track_id.as_ref()); + let input_id_str = track_id.id().to_owned(); // 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 +705,12 @@ impl SpotifyDownloader { // Get stream from Spotify let sess = self.i.sp.session()?; - let (sp_item, file_id, audio_format) = self.load_track(track_id).await?; + let (sp_item, file_id, audio_format) = self.load_track(pid.as_ref()).await?; let cdn_url = CdnUrl::new(file_id) .resolve_audio(self.i.sp.session()?) .await?; let audio_url = cdn_url.try_get_url()?; - let key = self.get_file_key(track_id, file_id, audio_format).await?; + let key = self.get_file_key(pid, file_id, audio_format).await?; let track_fields = if let UniqueFields::Track(f) = &sp_item.unique_fields { f @@ -718,7 +719,7 @@ impl SpotifyDownloader { "podcast episodes are not supported", ))); }; - let n_discs = self.get_n_discs(&track_fields.album_id).await?; + let n_discs = self.get_n_discs(track_fields.album_id.as_ref()).await?; let title = &sp_item.name; let artist = track_fields @@ -735,8 +736,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(&SpotifyId::from_base62(&artist.id)?).await?; - let album_id_str = track_fields.album_id.to_base62(); + let genre = self.get_genre(ArtistId::from_id(&artist.id)?).await?; + let album_id_str = track_fields.album_id.id(); let artist_dir = path!(self.i.base_dir / better_filenamify(&album_artist.name, None)); if !artist_dir.is_dir() { @@ -744,7 +745,7 @@ impl SpotifyDownloader { let img_path = path!(artist_dir / "artist.jpg"); let artist = sess .spclient() - .pb_artist(&SpotifyId::from_base62(&album_artist.id)?, None) + .pb_artist(ArtistId::from_id(&album_artist.id)?, None) .await? .data; @@ -774,7 +775,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,44 +788,6 @@ 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; @@ -838,20 +801,16 @@ impl SpotifyDownloader { } } - let track_id_str = sp_item - .actual_id - .as_ref() - .unwrap_or(&sp_item.track_id) - .to_base62(); + let track_id_str = sp_item.actual_id.as_ref().unwrap_or(&sp_item.track_id).id(); 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 let Err(e) = tag_file( &tpath, - &track_id_str, + track_id_str, title, &album_artist.name, &track_fields.album_name, @@ -859,7 +818,7 @@ impl SpotifyDownloader { track_fields.disc_number, &artist.name, &artists_json, - &album_id_str, + album_id_str, Some(&album_artist.id), release_date_str.as_deref(), genre.as_deref(), @@ -950,9 +909,9 @@ impl SpotifyDownloader { /// This is the case if the download is either /// - successful /// - unavailable - pub async fn download_track_log(&self, track_id: &SpotifyId, force: bool) -> bool { + pub async fn download_track_log(&self, track_id: TrackId<'_>, force: bool) -> bool { for _ in 0..3 { - match self.download_track(track_id, force).await { + match self.download_track(track_id.as_ref(), force).await { Ok(sp_item) => { if let Some(sp_item) = &sp_item { if let UniqueFields::Track(sp_track) = &sp_item.unique_fields { @@ -991,15 +950,15 @@ impl SpotifyDownloader { pub async fn download_artist( &self, - artist_id: &SpotifyId, + artist_id: ArtistId<'_>, if_none_match: Option<&str>, ) -> Result { - let artist_id_str = artist_id.to_base62(); + let artist_id_str = artist_id.id(); let artist_res = self .i .sp .spclient()? - .pb_artist(artist_id, if_none_match) + .pb_artist(artist_id.as_ref(), if_none_match) .await .into_option()?; @@ -1015,7 +974,7 @@ impl SpotifyDownloader { .chain(artist.single_group) .chain(artist.compilation_group) .filter_map(|b| b.album.into_iter().next()) - .map(|b| SpotifyId::from_raw(b.gid()).map(SpotifyId::album)) + .map(|b| AlbumId::from_raw(b.gid())) .collect::, _>>()?; tracing::info!( @@ -1027,7 +986,7 @@ impl SpotifyDownloader { let mut success = true; for album_id in &album_ids { - success &= self.download_album(album_id).await?; + success &= self.download_album(album_id.as_ref()).await?; } if success { @@ -1051,9 +1010,15 @@ impl SpotifyDownloader { Ok(success) } - 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, None).await?.data; + 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; let row = sqlx::query!(r#"select id from albums where id=$1"#, album_id_str) .fetch_optional(&self.i.pool) @@ -1066,13 +1031,13 @@ impl SpotifyDownloader { N_DISCS_CACHE .write() .unwrap() - .insert(album_id.to_base62().into_owned(), n_discs); + .insert(album_id_str.to_owned(), n_discs); let track_ids = album .disc .iter() .flat_map(|d| &d.track) - .map(|t| SpotifyId::from_raw(t.gid()).map(SpotifyId::track)) + .map(|t| TrackId::from_raw(t.gid())) .collect::, _>>()?; let success = Arc::new(AtomicBool::new(true)); @@ -1081,7 +1046,7 @@ impl SpotifyDownloader { .for_each_concurrent(4, |id| { let success = success.clone(); async move { - let ok = self.download_track_log(id, false).await; + let ok = self.download_track_log(id.as_ref(), false).await; if !ok { success.store(false, Ordering::SeqCst); } @@ -1125,9 +1090,9 @@ impl SpotifyDownloader { if let Some(progress) = &progress { progress.inc(1); } - let artist_id = SpotifyId::from_base62(&row.id.unwrap()).unwrap().artist(); async move { - self.download_artist(&artist_id, row.etag.as_deref()).await?; + 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?; Ok(()) } }) @@ -1204,7 +1169,7 @@ fn map_track(session: &Session, track: Track) -> Result { .ok_or(MetadataError::Missing("album"))?; Ok(AudioItem { - track_id: SpotifyId::from_raw(track.gid())?.track(), + track_id: PlayableId::Track(TrackId::from_raw(track.gid())?), actual_id: None, files: AudioFiles::from(track.file.as_slice()), covers: get_covers(album.cover_group.image.iter().chain(&album.cover)), @@ -1224,7 +1189,7 @@ fn map_track(session: &Session, track: Track) -> Result { .iter() .map(|track| { Ok(AlternativeTrack { - track_id: SpotifyId::from_raw(track.gid())?.track(), + track_id: TrackId::from_raw(track.gid())?, availability: availability( session, &track.availability, @@ -1236,7 +1201,7 @@ fn map_track(session: &Session, track: Track) -> Result { }) .collect::, Error>>()?, unique_fields: UniqueFields::Track(TrackUniqueFields { - album_id: SpotifyId::from_raw(album.gid())?.album(), + album_id: AlbumId::from_raw(album.gid())?, artists: track .artist_with_role .into_iter() diff --git a/crates/downloader/src/main.rs b/crates/downloader/src/main.rs index ef7db96..a7c2040 100644 --- a/crates/downloader/src/main.rs +++ b/crates/downloader/src/main.rs @@ -11,10 +11,9 @@ use std::{ use clap::{Parser, Subcommand}; use futures_util::{stream, StreamExt, TryStreamExt}; use indicatif::MultiProgress; -use reqwest::Url; use spotifyio::{ - ApplicationCache, AuthCredentials, AuthenticationType, Quota, SearchResult, SearchType, - Session, SessionConfig, SpotifyId, + model::{AlbumId, ArtistId, Id, IdConstruct, SearchResult, SearchType, TrackId}, + ApplicationCache, AuthCredentials, AuthenticationType, Quota, Session, SessionConfig, }; use spotifyio_downloader::{Error, SpotifyDownloader, SpotifyDownloaderConfig}; use tracing::level_filters::LevelFilter; @@ -198,15 +197,15 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { dl.migrate().await?; } Commands::AddArtist { artist } => { - let parsed = parse_url_ids(&artist); + let parsed = parse_url_ids::(artist.iter()); let artists = if let Ok(parsed) = parsed { - let got_artists = dl.session()?.spclient().pb_artists(parsed.iter()).await?; + let got_artists = dl.session()?.spclient().pb_artists(parsed).await?; got_artists .iter() .map(|a| { Ok(spotifyio_downloader::model::ArtistItem { - id: SpotifyId::from_raw(a.gid())?.to_base62().into_owned(), + id: ArtistId::from_raw(a.gid())?.into_id(), name: a.name().to_owned(), }) }) @@ -221,7 +220,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.to_base62()); + println!("{}: {} [{}]", i, a.name, a.id.id()); } let stdin = std::io::stdin(); let mut buf = String::new(); @@ -235,7 +234,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.to_base62().into_owned(), + id: a.id.id().to_owned(), name: a.name.to_owned(), }] } else { @@ -273,7 +272,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>) @@ -281,7 +280,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.artist(), None).await?; + let ok = dl.download_artist(id.as_ref(), None).await?; if !ok { success.store(false, Ordering::SeqCst); } @@ -296,7 +295,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>) @@ -304,7 +303,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.album()).await?; + let ok = dl.download_album(id).await?; if !ok { success.store(false, Ordering::SeqCst); } @@ -319,14 +318,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.track(), force).await; + let ok = dl.download_track_log(id.as_ref(), force).await; if !ok { success.store(false, Ordering::SeqCst); } @@ -366,22 +365,11 @@ fn parse_quota(s: &str) -> Result { Ok(quota) } -fn parse_url_or_id(s: &str) -> Result { - if let Ok(id) = SpotifyId::from_id_or_uri(s) { - if !id.is_textual() { - 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> { +fn parse_url_ids<'a, T: IdConstruct<'a>>( + s: impl IntoIterator, +) -> Result, Error> { s.into_iter() - .map(|s| parse_url_or_id(s.as_ref())) - .collect::, _>>() + .map(|s| T::from_id_uri_or_url(s)) + .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 e8f8ca3..3749b80 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::{pb::AudioFileFormat, FileId}; +use spotifyio::{model::FileId, pb::AudioFileFormat}; 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 00779cd..074e15f 100644 --- a/crates/downloader/src/model/mod.rs +++ b/crates/downloader/src/model/mod.rs @@ -1,7 +1,8 @@ use serde::Serialize; use spotifyio::{ + model::{AlbumId, ArtistId, FileId, IdConstruct, PlayableId, TrackId}, pb::{ArtistRole as PbArtistRole, ImageSize}, - Error, FileId, SpotifyId, + Error, }; use spotifyio_protocol::metadata::{ album::Type as AlbumType, Artist as ArtistMessage, ArtistWithRole as ArtistWithRoleMessage, @@ -25,8 +26,8 @@ pub struct CoverImage { #[derive(Debug, Clone)] pub struct AudioItem { - pub track_id: SpotifyId, - pub actual_id: Option, + pub track_id: PlayableId<'static>, + pub actual_id: Option>, pub files: AudioFiles, pub name: String, pub covers: Vec, @@ -42,7 +43,7 @@ pub struct AudioItem { #[derive(Debug, Clone)] pub struct TrackUniqueFields { pub artists: Vec, - pub album_id: SpotifyId, + pub album_id: AlbumId<'static>, pub album_name: String, pub album_artists: Vec, pub album_type: AlbumType, @@ -65,7 +66,7 @@ pub enum UniqueFields { #[derive(Debug, Clone)] pub struct AlternativeTrack { - pub track_id: SpotifyId, + pub track_id: TrackId<'static>, pub availability: AudioItemAvailability, pub files: AudioFiles, } @@ -87,9 +88,7 @@ impl TryFrom for ArtistWithRole { type Error = Error; fn try_from(value: ArtistWithRoleMessage) -> Result { Ok(Self { - id: SpotifyId::from_raw(value.artist_gid())? - .to_base62() - .into_owned(), + id: ArtistId::from_raw(value.artist_gid())?.into_id(), role: value.role().into(), name: value.artist_name.unwrap_or_default(), }) @@ -101,7 +100,7 @@ impl TryFrom for ArtistItem { fn try_from(value: ArtistMessage) -> Result { Ok(Self { - id: SpotifyId::from_raw(value.gid())?.to_base62().into_owned(), + id: ArtistId::from_raw(value.gid())?.into_id(), name: value.name.unwrap_or_default(), }) } diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index fce4730..ae1a727 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -21,3 +21,4 @@ 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 30084c5..48386d1 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::{ - AlbumType, Copyright, DatePrecision, Image, Page, RestrictionReason, SimplifiedArtist, - SimplifiedTrack, SpotifyId, + AlbumId, AlbumType, Copyright, DatePrecision, Image, Page, RestrictionReason, SimplifiedArtist, + SimplifiedTrack, }; /// Simplified Album Object @@ -20,8 +20,7 @@ pub struct SimplifiedAlbum { #[serde(skip_serializing_if = "Vec::is_empty", default)] pub available_markets: Vec, pub href: Option, - #[serde(with = "crate::spotify_id::ser::album::option")] - pub id: Option, + pub id: Option>, pub images: Vec, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -42,8 +41,7 @@ pub struct FullAlbum { pub external_ids: HashMap, pub genres: Vec, pub href: String, - #[serde(with = "crate::spotify_id::ser::album")] - pub id: SpotifyId, + pub id: AlbumId<'static>, pub images: Vec, pub name: String, pub popularity: u32, diff --git a/crates/model/src/artist.rs b/crates/model/src/artist.rs index 36d033a..bea3439 100644 --- a/crates/model/src/artist.rs +++ b/crates/model/src/artist.rs @@ -2,14 +2,13 @@ use serde::{Deserialize, Serialize}; -use crate::{CursorBasedPage, Followers, Image, SpotifyId}; +use crate::{ArtistId, CursorBasedPage, Followers, Image}; /// Simplified Artist Object #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct SimplifiedArtist { pub href: Option, - #[serde(with = "crate::spotify_id::ser::artist::option")] - pub id: Option, + pub id: Option>, pub name: String, } @@ -19,8 +18,7 @@ pub struct FullArtist { pub followers: Followers, pub genres: Vec, pub href: String, - #[serde(with = "crate::spotify_id::ser::artist")] - pub id: SpotifyId, + pub id: ArtistId<'static>, pub images: Vec, pub name: String, pub popularity: u32, diff --git a/crates/model/src/audio.rs b/crates/model/src/audio.rs index 521be4b..dab7d8d 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, SpotifyId}; +use crate::{custom_serde::modality, Modality, TrackId}; /// Audio Feature Object #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -12,8 +12,7 @@ pub struct AudioFeatures { pub danceability: f32, pub duration_ms: u32, pub energy: f32, - #[serde(with = "crate::spotify_id::ser::track")] - pub id: SpotifyId, + pub id: TrackId<'static>, pub instrumentalness: f32, pub key: i32, pub liveness: f32, diff --git a/crates/model/src/context.rs b/crates/model/src/context.rs index 172c1e5..923a11e 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, Type}; +use crate::{CurrentlyPlayingType, Device, DisallowKey, PlayableItem, RepeatState, SpotifyType}; /// 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: Type, + pub _type: SpotifyType, } /// Currently playing object diff --git a/crates/model/src/enums/types.rs b/crates/model/src/enums/types.rs index 147c656..8c94970 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 Type { +pub enum SpotifyType { Artist, Album, Track, @@ -40,8 +40,10 @@ pub enum Type { 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 e4a5d22..5d63bb5 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::{spotify_id::to_base16, IdError}; +use crate::IdError; #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct FileId(pub [u8; 20]); @@ -25,21 +25,20 @@ impl FileId { Ok(FileId(dst)) } - #[allow(clippy::wrong_self_convention)] - pub fn to_base16(&self) -> Result { - to_base16(&self.0, &mut [0u8; 40]) + pub fn base16(&self) -> String { + HEXLOWER_PERMISSIVE.encode(&self.0) } } impl fmt::Debug for FileId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("FileId").field(&self.to_base16()).finish() + f.debug_tuple("FileId").field(&self.base16()).finish() } } impl fmt::Display for FileId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.to_base16().unwrap_or_default()) + f.write_str(&self.base16()) } } diff --git a/crates/model/src/idtypes.rs b/crates/model/src/idtypes.rs new file mode 100644 index 0000000..233b290 --- /dev/null +++ b/crates/model/src/idtypes.rs @@ -0,0 +1,1292 @@ +//! 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.uri()) + } + } + )+ + } +} + +// 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) + } +} + +#[enum_dispatch(Id)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum PlaylistItemId<'a> { + Track(TrackId<'a>), + Episode(EpisodeId<'a>), + Local(LocalId<'a>), +} + +// These don't work with `enum_dispatch`, unfortunately. +impl<'a> PlaylistItemId<'a> { + #[must_use] + pub fn as_ref(&'a self) -> Self { + match self { + PlaylistItemId::Track(x) => PlaylistItemId::Track(x.as_ref()), + PlaylistItemId::Episode(x) => PlaylistItemId::Episode(x.as_ref()), + PlaylistItemId::Local(x) => PlaylistItemId::Local(x.as_ref()), + } + } + + #[must_use] + pub fn into_static(self) -> PlaylistItemId<'static> { + match self { + PlaylistItemId::Track(x) => PlaylistItemId::Track(x.into_static()), + PlaylistItemId::Episode(x) => PlaylistItemId::Episode(x.into_static()), + PlaylistItemId::Local(x) => PlaylistItemId::Local(x.into_static()), + } + } + + #[must_use] + pub fn clone_static(&'a self) -> PlaylistItemId<'static> { + match self { + PlaylistItemId::Track(x) => PlaylistItemId::Track(x.clone_static()), + PlaylistItemId::Episode(x) => PlaylistItemId::Episode(x.clone_static()), + PlaylistItemId::Local(x) => PlaylistItemId::Local(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)?)), + SpotifyType::Local => Ok(Self::Local(LocalId::from_id(id)?)), + _ => Err(IdError::InvalidType), + } + } +} + +/// Displaying the ID shows its URI +impl std::fmt::Display for PlaylistItemId<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.uri()) + } +} + +impl Serialize for PlaylistItemId<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.uri()) + } +} + +impl<'de> Deserialize<'de> for PlaylistItemId<'static> { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct UriVisitor; + + impl serde::de::Visitor<'_> for UriVisitor { + type Value = PlaylistItemId<'static>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("URI for PlaylistItemId") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + PlaylistItemId::from_uri(v) + .map(PlaylistItemId::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_playlist_item_id() { + let track_uri = "spotify:track:4iV5W9uYEdYUVa79Axb7Rh"; + let id_track = PlaylistItemId::from_uri(track_uri).unwrap(); + assert_eq!(id_track.spotify_type(), SpotifyType::Track); + assert_eq!(id_track.id(), "4iV5W9uYEdYUVa79Axb7Rh"); + assert_eq!(id_track.uri(), track_uri); + + let episode_uri = "spotify:episode:512ojhOuo1ktJprKbVcKyQ"; + let id_episode = PlaylistItemId::from_uri(episode_uri).unwrap(); + assert_eq!(id_episode.spotify_type(), SpotifyType::Episode); + assert_eq!(id_episode.id(), "512ojhOuo1ktJprKbVcKyQ"); + assert_eq!(id_episode.uri(), episode_uri); + + let local_uri = + "spotify:local:David+Wise:Donkey+Kong+Country%3A+Tropical+Freeze:Snomads+Island:127"; + let id_local = PlaylistItemId::from_uri(local_uri).unwrap(); + assert_eq!(id_local.spotify_type(), SpotifyType::Local); + assert_eq!( + id_local.id(), + "David+Wise:Donkey+Kong+Country%3A+Tropical+Freeze:Snomads+Island:127" + ); + assert_eq!(id_local.uri(), local_uri); + } + + #[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 daf6724..7b47616 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -11,6 +11,7 @@ mod device; mod enums; mod error; mod file_id; +mod idtypes; mod image; mod offset; mod page; @@ -19,14 +20,13 @@ 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::*, image::*, offset::*, page::*, playing::*, playlist::*, recommend::*, search::*, - show::*, spotify_id::*, track::*, user::*, + file_id::*, idtypes::*, image::*, offset::*, page::*, playing::*, playlist::*, recommend::*, + search::*, show::*, 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<&SpotifyId> { + pub fn id(&self) -> Option> { match self { - PlayableItem::Track(t) => t.id.as_ref(), - PlayableItem::Episode(e) => Some(&e.id), + PlayableItem::Track(t) => t.id.as_ref().map(|t| PlayableId::Track(t.as_ref())), + PlayableItem::Episode(e) => Some(PlayableId::Episode(e.id.as_ref())), } } } diff --git a/crates/model/src/playlist.rs b/crates/model/src/playlist.rs index 0a6d4e4..8ddb73e 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, PublicUser, SpotifyId}; +use crate::{Followers, Image, Page, PlayableItem, PlaylistId, PublicUser}; /// Playlist result object #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -31,8 +31,7 @@ where pub struct SimplifiedPlaylist { pub collaborative: bool, pub href: String, - #[serde(with = "crate::spotify_id::ser::playlist")] - pub id: SpotifyId, + pub id: PlaylistId<'static>, #[serde(deserialize_with = "deserialize_null_default")] pub images: Vec, pub name: String, @@ -49,8 +48,7 @@ pub struct FullPlaylist { pub description: Option, pub followers: Followers, pub href: String, - #[serde(with = "crate::spotify_id::ser::playlist")] - pub id: SpotifyId, + pub id: PlaylistId<'static>, pub images: Vec, pub name: String, pub owner: PublicUser, diff --git a/crates/model/src/show.rs b/crates/model/src/show.rs index bc3ac81..cb54ed8 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, Image, Page, SpotifyId}; +use crate::{CopyrightType, DatePrecision, EpisodeId, Image, Page, ShowId}; /// Copyright object #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -18,8 +18,7 @@ pub struct SimplifiedShow { pub description: String, pub explicit: bool, pub href: String, - #[serde(with = "crate::spotify_id::ser::show")] - pub id: SpotifyId, + pub id: ShowId<'static>, pub images: Vec, pub is_externally_hosted: Option, pub languages: Vec, @@ -50,8 +49,7 @@ pub struct FullShow { pub explicit: bool, pub episodes: Page, pub href: String, - #[serde(with = "crate::spotify_id::ser::show")] - pub id: SpotifyId, + pub id: ShowId<'static>, pub images: Vec, pub is_externally_hosted: Option, pub languages: Vec, @@ -68,8 +66,7 @@ pub struct SimplifiedEpisode { pub duration_ms: u32, pub explicit: bool, pub href: String, - #[serde(with = "crate::spotify_id::ser::episode")] - pub id: SpotifyId, + pub id: EpisodeId<'static>, pub images: Vec, pub is_externally_hosted: bool, pub is_playable: bool, @@ -92,8 +89,7 @@ pub struct FullEpisode { pub duration_ms: u32, pub explicit: bool, pub href: String, - #[serde(with = "crate::spotify_id::ser::episode")] - pub id: SpotifyId, + pub id: EpisodeId<'static>, pub images: Vec, pub is_externally_hosted: bool, pub is_playable: bool, diff --git a/crates/model/src/track.rs b/crates/model/src/track.rs index 32de0aa..1209499 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::{Restriction, SimplifiedAlbum, SimplifiedArtist, SpotifyId, Type}; +use crate::{PlayableId, Restriction, SimplifiedAlbum, SimplifiedArtist, SpotifyType, TrackId}; /// Full track object #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -20,8 +20,7 @@ pub struct FullTrack { pub external_ids: HashMap, pub href: Option, /// Note that a track may not have an ID/URI if it's local - #[serde(with = "crate::spotify_id::ser::track::option")] - pub id: Option, + pub id: Option>, pub is_local: bool, #[serde(skip_serializing_if = "Option::is_none")] pub is_playable: Option, @@ -40,9 +39,8 @@ pub struct FullTrack { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct TrackLink { pub href: String, - #[serde(with = "crate::spotify_id::ser::track::option")] - pub id: Option, - pub r#type: Type, + pub id: Option>, + pub r#type: SpotifyType, pub uri: String, } @@ -67,8 +65,7 @@ pub struct SimplifiedTrack { pub explicit: bool, #[serde(default)] pub href: Option, - #[serde(with = "crate::spotify_id::ser::track::option")] - pub id: Option, + pub id: Option>, pub is_local: bool, pub is_playable: Option, pub linked_from: Option, @@ -92,6 +89,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: SpotifyId, + pub id: PlayableId<'static>, pub positions: &'a [u32], } diff --git a/crates/model/src/user.rs b/crates/model/src/user.rs index 4529fd9..c09e1a4 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, SpotifyId, SubscriptionLevel}; +use crate::{Country, Followers, Image, SubscriptionLevel, UserId}; /// Public user object #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -10,8 +10,7 @@ pub struct PublicUser { pub display_name: Option, pub followers: Option, pub href: String, - #[serde(with = "crate::spotify_id::ser::user")] - pub id: SpotifyId, + pub id: UserId<'static>, #[serde(default = "Vec::new")] pub images: Vec, } @@ -25,8 +24,7 @@ pub struct PrivateUser { pub explicit_content: Option, pub followers: Option, pub href: String, - #[serde(with = "crate::spotify_id::ser::user")] - pub id: SpotifyId, + pub id: UserId<'static>, pub images: Option>, pub product: Option, } diff --git a/crates/spotifyio/src/audio_key.rs b/crates/spotifyio/src/audio_key.rs index 7263919..486bf86 100644 --- a/crates/spotifyio/src/audio_key.rs +++ b/crates/spotifyio/src/audio_key.rs @@ -2,11 +2,12 @@ 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, util::SeqGenerator, Error, FileId, SpotifyId}; +use crate::{connection::PacketType, model::FileId, util::SeqGenerator, Error}; #[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] pub struct AudioKey(pub [u8; 16]); @@ -85,7 +86,7 @@ impl AudioKeyManager { } #[tracing::instrument("audio_key", level = "error", skip(self), fields(usr = self.session().user_id()))] - pub async fn request(&self, track: &SpotifyId, file: &FileId) -> Result { + pub async fn request(&self, track: PlayableId<'_>, file: &FileId) -> Result { // Make sure a connection is established self.session().connect_stored_creds().await?; @@ -108,10 +109,15 @@ impl AudioKeyManager { } } - fn send_key_request(&self, seq: u32, track: &SpotifyId, file: &FileId) -> Result<(), Error> { + fn send_key_request( + &self, + seq: u32, + track: PlayableId<'_>, + file: &FileId, + ) -> Result<(), Error> { let mut data: Vec = Vec::new(); data.write_all(&file.0)?; - data.write_all(&track.to_raw()?)?; + data.write_all(&track.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 49cd0ef..7b13de0 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 super::{Error, FileId, Session}; +use crate::{model::FileId, Error, Session}; use protocol::storage_resolve::storage_resolve_response::Result as StorageResolveResponse_Result; use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage; diff --git a/crates/spotifyio/src/model.rs b/crates/spotifyio/src/gql_model.rs similarity index 95% rename from crates/spotifyio/src/model.rs rename to crates/spotifyio/src/gql_model.rs index 4dd00e8..023b8b2 100644 --- a/crates/spotifyio/src/model.rs +++ b/crates/spotifyio/src/gql_model.rs @@ -6,7 +6,10 @@ use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DefaultOnError, DisplayFromStr}; use time::OffsetDateTime; -use crate::SpotifyId; +use spotifyio_model::{ + AlbumId, ArtistId, ConcertId, PlaylistId, PlaylistItemId, PrereleaseId, SongwriterId, TrackId, + UserId, +}; #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct LyricsWrap { @@ -100,7 +103,7 @@ pub(crate) struct ArtistGqlWrap { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ArtistGql { - pub uri: SpotifyId, + pub uri: ArtistId<'static>, pub profile: ArtistProfile, pub related_content: Option, pub stats: Option, @@ -187,7 +190,7 @@ pub struct Events { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct Concert { - pub uri: SpotifyId, + pub uri: ConcertId<'static>, pub title: String, pub date: DateWrap, #[serde(default)] @@ -269,7 +272,7 @@ pub struct Name { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct GqlPlaylistItem { - pub uri: SpotifyId, + pub uri: PlaylistItemId<'static>, pub name: String, pub images: GqlPagination, pub owner_v2: GqlWrap, @@ -279,7 +282,7 @@ 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 avatar: Option, @@ -289,7 +292,7 @@ pub struct UserItem { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ArtistItem { - pub uri: SpotifyId, + pub uri: ArtistId<'static>, pub profile: Name, pub visuals: Option, } @@ -316,7 +319,7 @@ pub(crate) struct PrereleaseLookup { #[non_exhaustive] pub struct PrereleaseItem { /// URI of the prerelease - pub uri: SpotifyId, + pub uri: PrereleaseId<'static>, pub pre_release_content: PrereleaseContent, pub release_date: DateWrap, } @@ -326,7 +329,7 @@ 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 artists: Option>>, @@ -338,7 +341,7 @@ pub struct PrereleaseContent { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct PrereleaseTrackItem { - pub uri: SpotifyId, + pub uri: TrackId<'static>, pub name: String, pub duration: Option, pub artists: GqlPagination>, @@ -405,7 +408,7 @@ pub enum AlbumItemWrap { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct AlbumItem { - pub uri: SpotifyId, + pub uri: AlbumId<'static>, pub name: String, pub date: Option, pub cover_art: Option, @@ -416,7 +419,7 @@ pub struct AlbumItem { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct TrackItem { - pub uri: SpotifyId, + pub uri: TrackId<'static>, pub name: String, pub duration: DurationWrap, pub artists: GqlPagination, @@ -433,7 +436,7 @@ pub(crate) struct ConcertGqlWrap { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ConcertGql { - pub uri: SpotifyId, + pub uri: ConcertId<'static>, pub title: String, #[serde(default)] pub artists: GqlPagination>, @@ -491,7 +494,7 @@ pub enum SearchItemType { #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub struct UserProfile { - pub uri: SpotifyId, + pub uri: UserId<'static>, pub name: Option, pub image_url: Option, pub followers_count: Option, @@ -512,10 +515,10 @@ pub struct UserPlaylists { #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub struct PublicPlaylistItem { - pub uri: SpotifyId, + pub uri: PlaylistId<'static>, pub name: String, pub owner_name: String, - pub owner_uri: SpotifyId, + pub owner_uri: UserId<'static>, /// UID-based image id /// /// - `spotify:image:ab67706c0000da8474fffd106bb7f5be3ba4b758` @@ -532,7 +535,7 @@ pub(crate) struct UserProfilesWrap { #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub struct FollowerItem { - pub uri: SpotifyId, + pub uri: UserId<'static>, pub name: Option, pub followers_count: Option, pub image_url: Option, @@ -554,7 +557,7 @@ pub struct Seektable { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TrackCredits { - pub track_uri: SpotifyId, + pub track_uri: TrackId<'static>, pub track_title: String, #[serde(default)] pub role_credits: Vec, @@ -574,8 +577,8 @@ pub struct RoleCredits { #[serde(rename_all = "camelCase")] pub struct CreditedArtist { pub name: String, - #[serde(alias = "creatorUri")] - pub uri: Option, + pub uri: Option>, + pub creator_uri: Option>, pub external_url: Option, /// Image URL pub image_uri: Option, diff --git a/crates/spotifyio/src/lib.rs b/crates/spotifyio/src/lib.rs index c227556..741419a 100644 --- a/crates/spotifyio/src/lib.rs +++ b/crates/spotifyio/src/lib.rs @@ -18,7 +18,7 @@ mod pool; mod session; mod spclient; -pub mod model; +pub mod gql_model; pub mod util; #[cfg(feature = "oauth")] @@ -43,12 +43,10 @@ pub use normalisation::NormalisationData; pub use pool::PoolError; pub use session::{Session, SessionConfig}; pub use spclient::{EtagResponse, 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; @@ -69,6 +67,7 @@ pub mod pb { ListAttributeKind, }; pub use spotifyio_protocol::playlist_permission::PermissionLevel; + pub use spotifyio_protocol::*; } #[derive(Clone)] diff --git a/crates/spotifyio/src/oauth.rs b/crates/spotifyio/src/oauth.rs index f8b491a..81e8dd6 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::PrivateUser; +use spotifyio_model::{Id, 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.to_base62().into_owned()); + let sc = app_cache.new_session(profile.id.id().to_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 cdc8385..04589e7 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,18 +387,12 @@ impl Session { } } - pub fn image_url(&self, image_id: &FileId) -> Result { - Ok(format!( - "https://i.scdn.co/image/{}", - &image_id.to_base16()? - )) + pub fn image_url(&self, image_id: &FileId) -> String { + format!("https://i.scdn.co/image/{}", &image_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 audio_preview_url(&self, preview_id: &FileId) -> String { + format!("https://p.scdn.co/mp3-preview/{}", &preview_id.base16()) } pub fn shutdown(&self) { @@ -738,11 +732,10 @@ 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] @@ -810,9 +803,9 @@ mod tests { async fn connection_key() { let s = Session::new(SessionConfig::default(), SessionCache::testing()); - let spotify_id = SpotifyId::from_base62("4AY7RuEn7nmnxtvya5ygCt").unwrap(); + let spotify_id = PlayableId::Track(TrackId::from_id("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 f5592e9..74dd472 100644 --- a/crates/spotifyio/src/spclient.rs +++ b/crates/spotifyio/src/spclient.rs @@ -8,14 +8,6 @@ use protobuf::{Enum, EnumOrUnknown, Message}; use reqwest::{header, Method, Response, StatusCode}; 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}; @@ -39,16 +31,26 @@ use spotifyio_protocol::{ use crate::{ error::ErrorKind, - model::{ + 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, + }, pagination::{paginate, paginate_with_ctx, Paginator}, session::SessionWeak, util::{self, SocketAddress}, - Error, FileId, Session, SpotifyId, SpotifyItemType, + Error, Session, }; #[derive(Deserialize)] @@ -715,13 +717,12 @@ impl SpClient { async fn get_metadata( &self, - item_type: SpotifyItemType, - id: &SpotifyId, + id: MetadataItemId<'_>, if_none_match: Option<&str>, ) -> Result, Error> { - debug!("getting metadata for {item_type} {id}"); + debug!("getting metadata for {id}"); self.request_get_pb( - &format!("/metadata/4/{}/{}", item_type, id.to_base16()?), + &format!("/metadata/4/{}/{}", id.spotify_type(), id.base16()), if_none_match, ) .await @@ -729,88 +730,90 @@ impl SpClient { pub async fn pb_artist( &self, - id: &SpotifyId, + id: ArtistId<'_>, if_none_match: Option<&str>, ) -> Result, Error> { - self.get_metadata(SpotifyItemType::Artist, id, if_none_match) + self.get_metadata(MetadataItemId::Artist(id), if_none_match) .await } pub async fn pb_album( &self, - id: &SpotifyId, + id: AlbumId<'_>, if_none_match: Option<&str>, ) -> Result, Error> { - self.get_metadata(SpotifyItemType::Album, id, if_none_match) + self.get_metadata(MetadataItemId::Album(id), if_none_match) .await } pub async fn pb_track( &self, - id: &SpotifyId, + id: TrackId<'_>, if_none_match: Option<&str>, ) -> Result, Error> { - self.get_metadata(SpotifyItemType::Track, id, if_none_match) + self.get_metadata(MetadataItemId::Track(id), if_none_match) .await } pub async fn pb_show( &self, - id: &SpotifyId, + id: ShowId<'_>, if_none_match: Option<&str>, ) -> Result, Error> { - self.get_metadata(SpotifyItemType::Show, id, if_none_match) + self.get_metadata(MetadataItemId::Show(id), if_none_match) .await } pub async fn pb_episode( &self, - id: &SpotifyId, + id: EpisodeId<'_>, if_none_match: Option<&str>, ) -> Result, Error> { - self.get_metadata(SpotifyItemType::Episode, id, if_none_match) + self.get_metadata(MetadataItemId::Episode(id), if_none_match) .await } pub async fn pb_playlist( &self, - id: &SpotifyId, + id: PlaylistId<'_>, if_none_match: Option<&str>, ) -> Result, Error> { debug!("getting metadata for playlist {id}"); - self.request_get_pb( - &format!("/playlist/v2/playlist/{}", id.to_base62()), - if_none_match, - ) - .await + self.request_get_pb(&format!("/playlist/v2/playlist/{}", id.id()), if_none_match) + .await } pub async fn pb_playlist_diff( &self, - id: &SpotifyId, + id: PlaylistId<'_>, revision: &[u8], ) -> Result { debug!("getting diff for playlist {id}"); let endpoint = format!( "/playlist/v2/playlist/{}/diff?revision={},{}", - id.to_base62(), + id.id(), BigEndian::read_u32(&revision[0..4]), HEXLOWER_PERMISSIVE.encode(&revision[4..]), ); Ok(self.request_get_pb(&endpoint, None).await?.data) } - async fn pb_extended_metadata( + async fn pb_extended_metadata( &self, - ids: impl IntoIterator + Send, - item_type: SpotifyItemType, + 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()); + }; let kind = match item_type { - 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, + 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, _ => return Err(Error::invalid_argument("unsupported item type")), }; @@ -819,17 +822,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.clone().into_type(item_type).to_uri(); + r.entity_uri = id.uri(); let mut rq1 = ExtensionQuery::new(); rq1.extension_kind = EnumOrUnknown::new(kind); r.query.push(rq1); - r + Ok(r) }) - .collect(); + .collect::, Error>>()?; + debug!( "getting metadata for {} {}s", request.entity_request.len(), @@ -860,39 +863,37 @@ impl SpClient { pub async fn pb_artists( &self, - ids: impl IntoIterator + Send, + ids: impl IntoIterator> + Send, ) -> Result, Error> { - self.pb_extended_metadata(ids, SpotifyItemType::Artist) - .await + self.pb_extended_metadata(ids).await } pub async fn pb_albums( &self, - ids: impl IntoIterator + Send, + ids: impl IntoIterator> + Send, ) -> Result, Error> { - self.pb_extended_metadata(ids, SpotifyItemType::Album).await + self.pb_extended_metadata(ids).await } pub async fn pb_tracks( &self, - ids: impl IntoIterator + Send, + ids: impl IntoIterator> + Send, ) -> Result, Error> { - self.pb_extended_metadata(ids, SpotifyItemType::Track).await + self.pb_extended_metadata(ids).await } pub async fn pb_shows( &self, - ids: impl IntoIterator + Send, + ids: impl IntoIterator> + Send, ) -> Result, Error> { - self.pb_extended_metadata(ids, SpotifyItemType::Show).await + self.pb_extended_metadata(ids).await } pub async fn pb_episodes( &self, - ids: impl IntoIterator + Send, + ids: impl IntoIterator> + Send, ) -> Result, Error> { - self.pb_extended_metadata(ids, SpotifyItemType::Episode) - .await + self.pb_extended_metadata(ids).await } pub async fn pb_audio_storage( @@ -902,7 +903,7 @@ impl SpClient { debug!("getting audio storage for {file_id}"); let endpoint = format!( "/storage-resolve/files/audio/interactive/{}", - file_id.to_base16()? + file_id.base16() ); Ok(self.request_get_pb(&endpoint, None).await?.data) } @@ -929,7 +930,7 @@ impl SpClient { let license = self .request_pb::( Method::POST, - &format!("/playplay/v1/key/{}", file_id.to_base16()?), + &format!("/playplay/v1/key/{}", file_id.base16()), &req, ) .await?; @@ -941,7 +942,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,7 +956,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 => "?", @@ -977,7 +978,7 @@ impl SpClient { debug!("getting seektable {file_id}"); let url = format!( "https://seektables.scdn.co/seektable/{}.json", - file_id.to_base16()? + file_id.base16() ); Ok(self .session() @@ -1022,25 +1023,25 @@ impl SpClient { } /// Get information about a track's artists, writers and producers - pub async fn get_track_credits(&self, track_id: &SpotifyId) -> Result { + pub async fn get_track_credits(&self, track_id: TrackId<'_>) -> Result { debug!("getting track credits for {track_id}"); self.request_get_json(&format!( "/track-credits-view/v0/experimental/{}/credits", - track_id.to_base62() + track_id.id() )) .await } pub async fn pb_canvases( &self, - ids: impl IntoIterator + Send, - ) -> Result, Error> { + ids: impl IntoIterator> + Send, + ) -> Result, Canvaz>, Error> { let mut request = EntityCanvazRequest::new(); request.entities = ids .into_iter() .map(|id| { let mut entity = spotifyio_protocol::canvaz::entity_canvaz_request::Entity::new(); - entity.entity_uri = id.to_uri(); + entity.entity_uri = id.uri(); entity }) .collect(); @@ -1052,15 +1053,15 @@ impl SpClient { resp.canvases .into_iter() - .map(|c| Ok((SpotifyId::from_uri(&c.entity_uri)?, c))) + .map(|c| Ok((TrackId::from_uri(&c.entity_uri)?.into_static(), c))) .collect::, Error>>() } - pub async fn pb_canvas(&self, id: &SpotifyId) -> Result { + pub async fn pb_canvas(&self, id: TrackId<'_>) -> Result { debug!("getting canvas for {id}"); let mut request = EntityCanvazRequest::new(); let mut entity = spotifyio_protocol::canvaz::entity_canvaz_request::Entity::new(); - entity.entity_uri = id.to_uri(); + entity.entity_uri = id.uri(); request.entities.push(entity); let resp = self @@ -1072,19 +1073,19 @@ impl SpClient { .ok_or_else(|| Error::not_found(format!("canvas for {id}"))) } - pub async fn get_lyrics(&self, id: &SpotifyId) -> Result { + pub async fn get_lyrics(&self, id: TrackId<'_>) -> Result { debug!("getting lyrics for {id}"); let res = self - .request_get_json::(&format!("/color-lyrics/v2/track/{}", id.to_base62())) + .request_get_json::(&format!("/color-lyrics/v2/track/{}", id.id())) .await?; Ok(res.lyrics) } - pub async fn gql_artist_overview(&self, id: &SpotifyId) -> Result { + pub async fn gql_artist_overview(&self, id: ArtistId<'_>) -> Result { debug!("getting artist overview for {id}"); 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.to_base62() + id.id() ); let res = self .request_get_json::>(&url) @@ -1092,11 +1093,11 @@ impl SpClient { Ok(res.data.artist_union) } - pub async fn gql_artist_concerts(&self, id: &SpotifyId) -> Result, Error> { + pub async fn gql_artist_concerts(&self, id: ArtistId<'_>) -> Result, Error> { debug!("getting artist concerts for {id}"); 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.to_base62() + id.id() ); let res = self .request_get_json::>(&url) @@ -1104,9 +1105,9 @@ impl SpClient { Ok(res.data.artist_union.goods.events.concerts.items) } - pub async fn gql_concert(&self, id: &SpotifyId) -> Result { + pub async fn gql_concert(&self, id: ConcertId<'_>) -> Result { debug!("getting concert {id}"); - let cid = id.to_base62(); + let cid = id.id(); 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 @@ -1121,11 +1122,11 @@ impl SpClient { } } - pub async fn gql_prerelease(&self, id: &SpotifyId) -> Result { + pub async fn gql_prerelease(&self, id: PrereleaseId<'_>) -> Result { debug!("getting prerelease {id}"); 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.to_base62() + id.id() ); let res = self .request_get_json::>(&url) @@ -1162,9 +1163,9 @@ impl SpClient { Ok(res.data.search_v2) } - pub async fn gql_playlist(&self, id: &SpotifyId) -> Result { + 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.to_base62()); + 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 @@ -1175,44 +1176,44 @@ impl SpClient { pub async fn get_user_profile( &self, - id: &SpotifyId, + id: UserId<'_>, playlist_limit: u32, ) -> Result { debug!("getting user profile {id}"); self.request_get_json(&format!( "/user-profile-view/v3/profile/{}?playlist_limit={playlist_limit}&market=from_token", - id.to_base62() + id.id() )) .await } pub async fn get_user_playlists( &self, - id: &SpotifyId, + 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.to_base62())).await + self.request_get_json(&format!("/user-profile-view/v3/profile/{}/playlists?offset={offset}&limit={limit}&market=from_token", id.id())).await } - pub async fn get_user_followers(&self, id: &SpotifyId) -> Result, Error> { + pub async fn get_user_followers(&self, id: UserId<'_>) -> Result, Error> { debug!("getting user followers {id}"); let res = self .request_get_json::(&format!( "/user-profile-view/v3/profile/{}/followers?market=from_token", - id.to_base62() + id.id() )) .await?; Ok(res.profiles) } - pub async fn get_user_following(&self, id: &SpotifyId) -> Result, Error> { + pub async fn get_user_following(&self, id: UserId<'_>) -> Result, Error> { debug!("getting user following {id}"); let res = self .request_get_json::(&format!( "/user-profile-view/v3/profile/{}/following?market=from_token", - id.to_base62() + id.id() )) .await?; Ok(res.profiles) @@ -1228,13 +1229,13 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-track) pub async fn web_track( &self, - id: &SpotifyId, + id: TrackId<'_>, market: Option, ) -> Result { debug!("getting track {id} (web)"); let params = build_map([("market", market.map(Into::into))]); - let url = format!("/tracks/{}", id.to_base62()); + let url = format!("/tracks/{}", id.id()); self.public_get(&url, Some(¶ms)).await } @@ -1247,7 +1248,7 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-several-tracks) pub async fn web_tracks( &self, - track_ids: impl IntoIterator + Send, + track_ids: impl IntoIterator> + Send, market: Option, ) -> Result, Error> { let ids = join_ids(track_ids); @@ -1265,9 +1266,9 @@ 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: &SpotifyId) -> Result { + pub async fn web_artist(&self, artist_id: ArtistId<'_>) -> Result { debug!("getting artist {artist_id} (web)"); - let url = format!("/artists/{}", artist_id.to_base62()); + let url = format!("/artists/{}", artist_id.id()); self.public_get(&url, None).await } @@ -1279,7 +1280,7 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-artists) pub async fn web_artists( &self, - artist_ids: impl IntoIterator + Send, + artist_ids: impl IntoIterator> + Send, ) -> Result, Error> { let ids = join_ids(artist_ids); debug!("getting artists: {ids} (web)"); @@ -1303,7 +1304,7 @@ 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: &'a SpotifyId, + artist_id: ArtistId<'a>, include_groups: impl IntoIterator + Send + Copy + 'a, market: Option, ) -> Paginator<'a, Result> { @@ -1311,7 +1312,7 @@ impl SpClient { (self, artist_id), move |(slf, artist_id), limit, offset| { Box::pin(slf.web_artist_albums_manual( - artist_id, + artist_id.as_ref(), include_groups, market, Some(limit), @@ -1325,7 +1326,7 @@ impl SpClient { /// The manually paginated version of [`Self::artist_albums`]. pub async fn web_artist_albums_manual( &self, - artist_id: &SpotifyId, + artist_id: ArtistId<'_>, include_groups: impl IntoIterator + Send, market: Option, limit: Option, @@ -1351,7 +1352,7 @@ impl SpClient { ("offset", offset.as_deref()), ]); - let url = format!("/artists/{}/albums", artist_id.to_base62()); + let url = format!("/artists/{}/albums", artist_id.id()); self.public_get(&url, Some(¶ms)).await } @@ -1365,13 +1366,13 @@ 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: &SpotifyId, + artist_id: ArtistId<'_>, 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.to_base62()); + let url = format!("/artists/{}/top-tracks", artist_id.id()); let result = self.public_get::(&url, Some(¶ms)).await?; Ok(result.tracks) } @@ -1386,10 +1387,10 @@ 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: &SpotifyId, + artist_id: ArtistId<'_>, ) -> Result, Error> { debug!("getting related artists of {artist_id} (web)"); - let url = format!("/artists/{}/related-artists", artist_id.to_base62()); + let url = format!("/artists/{}/related-artists", artist_id.id()); let result = self.public_get::(&url, None).await?; Ok(result.artists) } @@ -1402,13 +1403,13 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-album) pub async fn web_album( &self, - album_id: &SpotifyId, + album_id: AlbumId<'_>, market: Option, ) -> Result { debug!("getting album {album_id} (web)"); let params = build_map([("market", market.map(Into::into))]); - let url = format!("/albums/{}", album_id.to_base62()); + let url = format!("/albums/{}", album_id.id()); self.public_get(&url, Some(¶ms)).await } @@ -1420,7 +1421,7 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-albums) pub async fn web_albums( &self, - album_ids: impl IntoIterator + Send, + album_ids: impl IntoIterator> + Send, market: Option, ) -> Result, Error> { let params = build_map([("market", market.map(Into::into))]); @@ -1543,13 +1544,18 @@ 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: &'a SpotifyId, + album_id: AlbumId<'a>, market: Option, ) -> Paginator<'a, Result> { paginate_with_ctx( (self, album_id), move |(slf, album_id), limit, offset| { - Box::pin(slf.web_album_track_manual(album_id, market, Some(limit), Some(offset))) + Box::pin(slf.web_album_track_manual( + album_id.as_ref(), + market, + Some(limit), + Some(offset), + )) }, self.session().config().pagination_chunks, ) @@ -1558,7 +1564,7 @@ impl SpClient { /// The manually paginated version of [`Self::album_track`]. pub async fn web_album_track_manual( &self, - album_id: &SpotifyId, + album_id: AlbumId<'_>, market: Option, limit: Option, offset: Option, @@ -1572,7 +1578,7 @@ impl SpClient { ("market", market.map(Into::into)), ]); - let url = format!("/albums/{}/tracks", album_id.to_base62()); + let url = format!("/albums/{}/tracks", album_id.id()); self.public_get(&url, Some(¶ms)).await } @@ -1582,9 +1588,9 @@ 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: &SpotifyId) -> Result { + pub async fn web_user(&self, user_id: UserId<'_>) -> Result { debug!("getting user {user_id} (web)"); - let url = format!("/users/{}", user_id.to_base62()); + let url = format!("/users/{}", user_id.id()); self.public_get(&url, None).await } @@ -1597,14 +1603,14 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-playlist) pub async fn web_playlist( &self, - playlist_id: &SpotifyId, + playlist_id: PlaylistId<'_>, 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.to_base62()); + let url = format!("/playlists/{}", playlist_id.id()); self.public_get(&url, Some(¶ms)).await } @@ -1618,20 +1624,16 @@ 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: &SpotifyId, - playlist_id: Option<&SpotifyId>, + user_id: UserId<'_>, + playlist_id: Option>, 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.to_base62(), - playlist_id.to_base62() - ), - None => format!("/users/{}/starred", user_id.to_base62()), + Some(playlist_id) => format!("/users/{}/playlists/{}", user_id.id(), playlist_id.id()), + None => format!("/users/{}/starred", user_id.id()), }; self.public_get(&url, Some(¶ms)).await } @@ -1646,14 +1648,14 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/check-if-user-follows-playlist) pub async fn web_playlist_check_follow( &self, - playlist_id: &SpotifyId, - user_ids: impl IntoIterator + Send, + playlist_id: PlaylistId<'_>, + 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.to_base62(), + playlist_id.id(), ids, ); self.public_get(&url, None).await @@ -1670,13 +1672,13 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-a-show) pub async fn web_get_a_show( &self, - id: &SpotifyId, + id: ShowId<'_>, market: Option, ) -> Result { debug!("getting show {id} (web)"); let params = build_map([("market", market.map(Into::into))]); - let url = format!("/shows/{}", id.to_base62()); + let url = format!("/shows/{}", id.id()); self.public_get(&url, Some(¶ms)).await } @@ -1690,7 +1692,7 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-shows) pub async fn web_get_several_shows( &self, - ids: impl IntoIterator + Send, + ids: impl IntoIterator> + Send, market: Option, ) -> Result, Error> { let ids = join_ids(ids); @@ -1720,13 +1722,18 @@ 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: &'a SpotifyId, + id: ShowId<'a>, market: Option, ) -> Paginator<'a, Result> { paginate_with_ctx( (self, id), move |(slf, id), limit, offset| { - Box::pin(slf.web_get_shows_episodes_manual(id, market, Some(limit), Some(offset))) + Box::pin(slf.web_get_shows_episodes_manual( + id.as_ref(), + market, + Some(limit), + Some(offset), + )) }, self.session().config().pagination_chunks, ) @@ -1735,7 +1742,7 @@ impl SpClient { /// The manually paginated version of [`Self::get_shows_episodes`]. pub async fn web_get_shows_episodes_manual( &self, - id: &SpotifyId, + id: ShowId<'_>, market: Option, limit: Option, offset: Option, @@ -1749,7 +1756,7 @@ impl SpClient { ("offset", offset.as_deref()), ]); - let url = format!("/shows/{}/episodes", id.to_base62()); + let url = format!("/shows/{}/episodes", id.id()); self.public_get(&url, Some(¶ms)).await } @@ -1764,12 +1771,12 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-episode) pub async fn web_get_an_episode( &self, - id: &SpotifyId, + id: EpisodeId<'_>, market: Option, ) -> Result { debug!("getting episode {id} (web)"); let params = build_map([("market", market.map(Into::into))]); - let url = format!("/episodes/{}", id.to_base62()); + let url = format!("/episodes/{}", id.id()); self.public_get(&url, Some(¶ms)).await } @@ -1782,7 +1789,7 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-episodes) pub async fn web_get_several_episodes( &self, - ids: impl IntoIterator + Send, + ids: impl IntoIterator> + Send, market: Option, ) -> Result, Error> { let ids = join_ids(ids); @@ -1801,9 +1808,9 @@ 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: &SpotifyId) -> Result { + 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.to_base62()); + let url = format!("/audio-features/{}", track_id.id()); self.public_get(&url, None).await } @@ -1815,7 +1822,7 @@ impl SpClient { /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-several-audio-features) pub async fn web_tracks_features( &self, - track_ids: impl IntoIterator + Send, + track_ids: impl IntoIterator> + Send, ) -> Result, Error> { let ids = join_ids(track_ids); debug!("getting track features for {ids} (web)"); @@ -1837,9 +1844,9 @@ 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: &SpotifyId) -> Result { + 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.to_base62()); + let url = format!("/audio-analysis/{}", track_id.id()); self.public_get(&url, None).await } @@ -2062,9 +2069,9 @@ impl SpClient { pub async fn web_recommendations( &self, attributes: impl IntoIterator + Send, - seed_artists: Option + Send>, + seed_artists: Option> + Send>, seed_genres: Option + Send>, - seed_tracks: Option + Send>, + seed_tracks: Option> + Send>, market: Option, limit: Option, ) -> Result { @@ -2112,7 +2119,7 @@ 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: &'a SpotifyId, + playlist_id: PlaylistId<'a>, fields: Option<&'a str>, market: Option, ) -> Paginator<'a, Result> { @@ -2120,7 +2127,7 @@ impl SpClient { (self, playlist_id, fields), move |(slf, playlist_id, fields), limit, offset| { Box::pin(slf.web_playlist_items_manual( - playlist_id, + playlist_id.as_ref(), *fields, market, Some(limit), @@ -2134,7 +2141,7 @@ impl SpClient { /// The manually paginated version of [`Self::playlist_items`]. pub async fn web_playlist_items_manual( &self, - playlist_id: &SpotifyId, + playlist_id: PlaylistId<'_>, fields: Option<&str>, market: Option, limit: Option, @@ -2150,7 +2157,7 @@ impl SpClient { ("offset", offset.as_deref()), ]); - let url = format!("/playlists/{}/tracks", playlist_id.to_base62()); + let url = format!("/playlists/{}/tracks", playlist_id.id()); self.public_get(&url, Some(¶ms)).await } @@ -2167,12 +2174,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: &'a SpotifyId, + user_id: UserId<'a>, ) -> Paginator<'a, Result> { paginate_with_ctx( (self, user_id), move |(slf, user_id), limit, offset| { - Box::pin(slf.web_user_playlists_manual(user_id, Some(limit), Some(offset))) + Box::pin(slf.web_user_playlists_manual(user_id.as_ref(), Some(limit), Some(offset))) }, self.session().config().pagination_chunks, ) @@ -2181,7 +2188,7 @@ impl SpClient { /// The manually paginated version of [`Self::user_playlists`]. pub async fn web_user_playlists_manual( &self, - user_id: &SpotifyId, + user_id: UserId<'_>, limit: Option, offset: Option, ) -> Result, Error> { @@ -2190,7 +2197,7 @@ impl SpClient { 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.to_base62()); + let url = format!("/users/{}/playlists", user_id.id()); self.public_get(&url, Some(¶ms)).await } } @@ -2209,9 +2216,9 @@ fn build_map<'key, 'value, const N: usize>( map } -fn join_ids<'a>(ids: impl IntoIterator) -> String { +fn join_ids(ids: impl IntoIterator) -> String { ids.into_iter() - .map(SpotifyId::to_base62) + .map(|id| id.id().to_owned()) .collect::>() .join(",") } @@ -2219,9 +2226,11 @@ fn join_ids<'a>(ids: impl IntoIterator) -> String { #[cfg(test)] mod tests { use futures_util::TryStreamExt; - use spotifyio_model::FileId; + use spotifyio_model::{ + ArtistId, FileId, IdConstruct, PlaylistId, PrereleaseId, TrackId, UserId, + }; - use crate::{cache::SessionCache, model::SearchItemType, Session, SessionConfig, SpotifyId}; + use crate::{cache::SessionCache, gql_model::SearchItemType, Session, SessionConfig}; async fn conn() -> Session { let s = Session::new(SessionConfig::default(), SessionCache::testing()); @@ -2235,7 +2244,7 @@ mod tests { let artist = s .spclient() .pb_artist( - &SpotifyId::from_base62("1EfwyuCzDQpCslZc8C9gkG").unwrap(), + ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap(), // Some("\"MC-EvTsdg==\""), None, ) @@ -2250,9 +2259,9 @@ mod tests { let artists = s .spclient() .pb_artists([ - &SpotifyId::from_base62("1EfwyuCzDQpCslZc8C9gkG").unwrap(), - &SpotifyId::from_base62("5RJFJWYgtgWktosLrUDzxx").unwrap(), // does not exist - &SpotifyId::from_base62("2NpPlwwDVYR5dIj0F31EcC").unwrap(), + ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap(), + ArtistId::from_id("5RJFJWYgtgWktosLrUDzxx").unwrap(), // does not exist + ArtistId::from_id("2NpPlwwDVYR5dIj0F31EcC").unwrap(), ]) .await .unwrap(); @@ -2264,7 +2273,7 @@ mod tests { let s = conn().await; let artist = s .spclient() - .gql_artist_overview(&SpotifyId::from_base62("1EfwyuCzDQpCslZc8C9gkG").unwrap()) + .gql_artist_overview(ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap()) .await .unwrap(); dbg!(&artist); @@ -2276,7 +2285,7 @@ mod tests { let search = s .spclient() .gql_prerelease( - &SpotifyId::from_uri("spotify:prerelease:44eAZjMWAxN7AaRHqLHWO5").unwrap(), + PrereleaseId::from_uri("spotify:prerelease:44eAZjMWAxN7AaRHqLHWO5").unwrap(), ) .await .unwrap(); @@ -2325,10 +2334,7 @@ mod tests { let s = conn().await; let user = s .spclient() - .get_user_profile( - &SpotifyId::from_base62("ustz0fgnbb2pjpjhu3num7b91").unwrap(), - 20, - ) + .get_user_profile(UserId::from_id("ustz0fgnbb2pjpjhu3num7b91").unwrap(), 20) .await .unwrap(); dbg!(&user); @@ -2340,7 +2346,7 @@ mod tests { let playlists = s .spclient() .get_user_playlists( - &SpotifyId::from_base62("ustz0fgnbb2pjpjhu3num7b91").unwrap(), + UserId::from_id("ustz0fgnbb2pjpjhu3num7b91").unwrap(), 0, 200, ) @@ -2354,7 +2360,7 @@ mod tests { let s = conn().await; let followers = s .spclient() - .get_user_followers(&SpotifyId::from_base62("c4vns19o05omhfw0p4txxvya7").unwrap()) + .get_user_followers(UserId::from_id("c4vns19o05omhfw0p4txxvya7").unwrap()) .await .unwrap(); dbg!(&followers); @@ -2365,7 +2371,7 @@ mod tests { let s = conn().await; let followers = s .spclient() - .get_user_following(&SpotifyId::from_base62("c4vns19o05omhfw0p4txxvya7").unwrap()) + .get_user_following(UserId::from_id("c4vns19o05omhfw0p4txxvya7").unwrap()) .await .unwrap(); dbg!(&followers); @@ -2377,7 +2383,7 @@ mod tests { let albums = s .spclient() .web_artist_albums( - &SpotifyId::from_base62("1EfwyuCzDQpCslZc8C9gkG").unwrap(), + ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap(), None, None, ) @@ -2393,7 +2399,7 @@ mod tests { let track = s .spclient() .web_track( - &SpotifyId::from_base62("4Lzbwc1IKHCDddL17xJjxV").unwrap(), + TrackId::from_id("4Lzbwc1IKHCDddL17xJjxV").unwrap(), Some(spotifyio_model::Market::FromToken), ) .await @@ -2433,7 +2439,7 @@ mod tests { let s = conn().await; let pl = s .spclient() - .gql_playlist(&SpotifyId::from_base62("1Th7TfDCbL8I9v6nRhWObF").unwrap()) + .gql_playlist(PlaylistId::from_id("1Th7TfDCbL8I9v6nRhWObF").unwrap()) .await .unwrap(); dbg!(&pl); From b774098619f061140a59a8bafada3b6a6a5c4351 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Wed, 11 Dec 2024 19:31:12 +0100 Subject: [PATCH 09/10] feat: add more commands to CLI --- crates/cli/src/main.rs | 111 ++++++++++++++++------- crates/model/src/idtypes.rs | 140 +----------------------------- crates/model/src/lib.rs | 2 +- crates/spotifyio/src/gql_model.rs | 5 +- 4 files changed, 86 insertions(+), 172 deletions(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 0d8384c..c9330f1 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -21,6 +21,9 @@ struct Cli { command: Commands, #[clap(long, default_value = "en")] lang: String, + /// Path to Spotify account cache + #[clap(long)] + cache: Option, } #[derive(Subcommand)] @@ -38,6 +41,8 @@ enum Commands { id: String, #[clap(long)] key: bool, + #[clap(long)] + web: bool, }, Album { id: String, @@ -61,6 +66,15 @@ enum Commands { id: String, }, Upkeep, + AudioFeatures { + id: String, + }, + AudioAnalysis { + id: String, + }, + Convertid { + b64: String, + }, } #[tokio::main] @@ -74,11 +88,13 @@ async fn main() -> Result<(), Error> { .unwrap(), ) .init(); + let cli = Cli::parse(); - let path = path!(env!("CARGO_MANIFEST_DIR") / ".." / ".." / "data" / "spotifyio.json"); + let path = cli.cache.unwrap_or(path!( + env!("CARGO_MANIFEST_DIR") / ".." / ".." / "data" / "spotifyio.json" + )); let app_cache = ApplicationCache::new(path); - let cli = Cli::parse(); let scfg = SessionConfig { language: cli.lang, ..Default::default() @@ -117,7 +133,7 @@ async fn main() -> Result<(), Error> { ); } } - Commands::Track { id, key } => { + Commands::Track { id, key, web } => { let pool = SpotifyIoPool::from_app_cache( app_cache, &scfg, @@ -128,35 +144,40 @@ async fn main() -> Result<(), Error> { )?; let spotify_id = parse_idstr(&id).unwrap(); - 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() - ); + 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() + ); - if key { - let spotify_id = TrackId::from_raw(track.gid()).unwrap(); - println!("Track id: {}", spotify_id.id()); + if key { + let spotify_id = TrackId::from_raw(track.gid()).unwrap(); + println!("Track id: {}", spotify_id.id()); - for file in &track.file { - println!("{} {:?}", FileId::from_raw(file.file_id())?, file.format()); + 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)); } - - 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)); } } Commands::Album { id } => { @@ -366,6 +387,38 @@ 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(()) } diff --git a/crates/model/src/idtypes.rs b/crates/model/src/idtypes.rs index 233b290..708ce69 100644 --- a/crates/model/src/idtypes.rs +++ b/crates/model/src/idtypes.rs @@ -592,7 +592,7 @@ macro_rules! define_idtypes { /// 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.uri()) + write!(f, "{}", self.id()) } } )+ @@ -843,119 +843,6 @@ impl<'de> Deserialize<'de> for PlayableId<'static> { } } -#[enum_dispatch(Id)] -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum PlaylistItemId<'a> { - Track(TrackId<'a>), - Episode(EpisodeId<'a>), - Local(LocalId<'a>), -} - -// These don't work with `enum_dispatch`, unfortunately. -impl<'a> PlaylistItemId<'a> { - #[must_use] - pub fn as_ref(&'a self) -> Self { - match self { - PlaylistItemId::Track(x) => PlaylistItemId::Track(x.as_ref()), - PlaylistItemId::Episode(x) => PlaylistItemId::Episode(x.as_ref()), - PlaylistItemId::Local(x) => PlaylistItemId::Local(x.as_ref()), - } - } - - #[must_use] - pub fn into_static(self) -> PlaylistItemId<'static> { - match self { - PlaylistItemId::Track(x) => PlaylistItemId::Track(x.into_static()), - PlaylistItemId::Episode(x) => PlaylistItemId::Episode(x.into_static()), - PlaylistItemId::Local(x) => PlaylistItemId::Local(x.into_static()), - } - } - - #[must_use] - pub fn clone_static(&'a self) -> PlaylistItemId<'static> { - match self { - PlaylistItemId::Track(x) => PlaylistItemId::Track(x.clone_static()), - PlaylistItemId::Episode(x) => PlaylistItemId::Episode(x.clone_static()), - PlaylistItemId::Local(x) => PlaylistItemId::Local(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)?)), - SpotifyType::Local => Ok(Self::Local(LocalId::from_id(id)?)), - _ => Err(IdError::InvalidType), - } - } -} - -/// Displaying the ID shows its URI -impl std::fmt::Display for PlaylistItemId<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self.uri()) - } -} - -impl Serialize for PlaylistItemId<'_> { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.uri()) - } -} - -impl<'de> Deserialize<'de> for PlaylistItemId<'static> { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct UriVisitor; - - impl serde::de::Visitor<'_> for UriVisitor { - type Value = PlaylistItemId<'static>; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("URI for PlaylistItemId") - } - - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - PlaylistItemId::from_uri(v) - .map(PlaylistItemId::into_static) - .map_err(serde::de::Error::custom) - } - } - - deserializer.deserialize_str(UriVisitor) - } -} - #[cfg(test)] mod test { use super::*; @@ -1098,31 +985,6 @@ mod test { check_static(id); } - #[test] - fn test_playlist_item_id() { - let track_uri = "spotify:track:4iV5W9uYEdYUVa79Axb7Rh"; - let id_track = PlaylistItemId::from_uri(track_uri).unwrap(); - assert_eq!(id_track.spotify_type(), SpotifyType::Track); - assert_eq!(id_track.id(), "4iV5W9uYEdYUVa79Axb7Rh"); - assert_eq!(id_track.uri(), track_uri); - - let episode_uri = "spotify:episode:512ojhOuo1ktJprKbVcKyQ"; - let id_episode = PlaylistItemId::from_uri(episode_uri).unwrap(); - assert_eq!(id_episode.spotify_type(), SpotifyType::Episode); - assert_eq!(id_episode.id(), "512ojhOuo1ktJprKbVcKyQ"); - assert_eq!(id_episode.uri(), episode_uri); - - let local_uri = - "spotify:local:David+Wise:Donkey+Kong+Country%3A+Tropical+Freeze:Snomads+Island:127"; - let id_local = PlaylistItemId::from_uri(local_uri).unwrap(); - assert_eq!(id_local.spotify_type(), SpotifyType::Local); - assert_eq!( - id_local.id(), - "David+Wise:Donkey+Kong+Country%3A+Tropical+Freeze:Snomads+Island:127" - ); - assert_eq!(id_local.uri(), local_uri); - } - #[test] fn test_user_urlencode() { let user_name = "Hello()World"; diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 7b47616..f05ecac 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -11,7 +11,7 @@ mod device; mod enums; mod error; mod file_id; -mod idtypes; +pub mod idtypes; mod image; mod offset; mod page; diff --git a/crates/spotifyio/src/gql_model.rs b/crates/spotifyio/src/gql_model.rs index 023b8b2..d9fcde6 100644 --- a/crates/spotifyio/src/gql_model.rs +++ b/crates/spotifyio/src/gql_model.rs @@ -7,8 +7,7 @@ use serde_with::{serde_as, DefaultOnError, DisplayFromStr}; use time::OffsetDateTime; use spotifyio_model::{ - AlbumId, ArtistId, ConcertId, PlaylistId, PlaylistItemId, PrereleaseId, SongwriterId, TrackId, - UserId, + AlbumId, ArtistId, ConcertId, PlaylistId, PrereleaseId, SongwriterId, TrackId, UserId, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -272,7 +271,7 @@ pub struct Name { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct GqlPlaylistItem { - pub uri: PlaylistItemId<'static>, + pub uri: PlaylistId<'static>, pub name: String, pub images: GqlPagination, pub owner_v2: GqlWrap, From 4a3b288a3c1b3bd5130872d64ae62996985dcfc1 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sat, 11 Jan 2025 21:22:31 +0100 Subject: [PATCH 10/10] feat: add audio features scraping, fix ogg file checking, return URL expiry date --- crates/cli/src/main.rs | 4 +- crates/downloader/Cargo.toml | 6 +- .../20241211183357_audio_features.sql | 1 + crates/downloader/src/lib.rs | 81 ++++++++++--------- crates/downloader/src/main.rs | 81 +++++++++++++++++-- crates/spotifyio/Cargo.toml | 2 +- crates/spotifyio/src/cdn_url.rs | 4 +- crates/spotifyio/src/connection/handshake.rs | 8 +- crates/spotifyio/src/gql_model.rs | 1 + crates/spotifyio/src/lib.rs | 13 ++- crates/spotifyio/src/normalisation.rs | 2 +- crates/spotifyio/src/util.rs | 19 +++++ 12 files changed, 166 insertions(+), 56 deletions(-) create mode 100644 crates/downloader/migrations/20241211183357_audio_features.sql diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index c9330f1..271a128 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -338,7 +338,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/spotifyio/crates/widevine/testfiles/application-certificate").unwrap(); + let certificate = std::fs::read("/home/thetadev/Documents/Programmieren/Rust/widevine-rs/crates/widevine/testfiles/application-certificate").unwrap(); let cdm = Cdm::new(device); let pssh = Pssh::from_b64(&seektable.pssh)?; @@ -353,7 +353,7 @@ async fn main() -> Result<(), Error> { let keys = lic.get_keys(&license_message)?; println!("[{}] {}", id, track.name()); - println!("{url}"); + println!("{}", url.0); dbg!(keys); } Commands::Upkeep => { diff --git a/crates/downloader/Cargo.toml b/crates/downloader/Cargo.toml index 1ea6c34..438e6b0 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.2.1" +version = "0.3.1" 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.21.0" +lofty = "0.22.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.13.0" +itertools = "0.14.0" reqwest = { version = "0.12.0", features = [ "stream", ], default-features = false } diff --git a/crates/downloader/migrations/20241211183357_audio_features.sql b/crates/downloader/migrations/20241211183357_audio_features.sql new file mode 100644 index 0000000..363d7c9 --- /dev/null +++ b/crates/downloader/migrations/20241211183357_audio_features.sql @@ -0,0 +1 @@ +alter table tracks add column audio_features TEXT; diff --git a/crates/downloader/src/lib.rs b/crates/downloader/src/lib.rs index 80e3cc5..b1bc9ae 100644 --- a/crates/downloader/src/lib.rs +++ b/crates/downloader/src/lib.rs @@ -100,6 +100,7 @@ pub struct SpotifyDownloaderInner { multi: Option, mp4decryptor: Option, cdm: Option, + apply_tags: bool, format_m4a: bool, widevine_cert: RwLock>, } @@ -271,6 +272,7 @@ pub struct SpotifyDownloaderConfig { pub progress: Option, pub widevine_device: Option, pub mp4decryptor: Option, + pub apply_tags: bool, pub format_m4a: bool, } @@ -285,6 +287,7 @@ impl Default for SpotifyDownloaderConfig { progress: None, widevine_device: None, mp4decryptor: Mp4Decryptor::from_env(), + apply_tags: true, format_m4a: false, } } @@ -321,6 +324,7 @@ impl SpotifyDownloader { multi: cfg.progress, cdm, mp4decryptor: cfg.mp4decryptor, + apply_tags: cfg.apply_tags, format_m4a: cfg.format_m4a, widevine_cert: RwLock::new(None), } @@ -424,7 +428,7 @@ impl SpotifyDownloader { spotify_id: PlayableId<'_>, ) -> Result<(AudioItem, FileId, AudioFileFormat), Error> { let audio_item = self.get_audio_item(spotify_id).await?; - let audio_item = self.find_available_alternative(audio_item).await?; + let audio_item = self.find_available_alternative(audio_item)?; tracing::info!( "Loading <{}> with Spotify URI <{}>", @@ -451,18 +455,13 @@ impl SpotifyDownloader { formats_default.as_slice() }; - 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())); - } - }; + 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()))?; Ok((audio_item, file_id, format)) } @@ -472,6 +471,7 @@ impl SpotifyDownloader { spotify_id: PlayableId<'_>, file_id: FileId, ) -> Result { + tracing::debug!("getting clearkey for {file_id}"); let key = self .i .sp @@ -485,6 +485,7 @@ 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 @@ -494,6 +495,7 @@ 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() @@ -590,10 +592,7 @@ impl SpotifyDownloader { Ok(()) } - async fn find_available_alternative( - &self, - mut audio_item: AudioItem, - ) -> Result { + 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); } @@ -709,7 +708,7 @@ impl SpotifyDownloader { let cdn_url = CdnUrl::new(file_id) .resolve_audio(self.i.sp.session()?) .await?; - let audio_url = cdn_url.try_get_url()?; + let audio_url = &cdn_url.try_get_url()?.0; let key = self.get_file_key(pid, file_id, audio_format).await?; let track_fields = if let UniqueFields::Track(f) = &sp_item.unique_fields { @@ -808,26 +807,29 @@ impl SpotifyDownloader { let cover_url = sess.image_url(&cover.id); let album_type_str = album_type_str(track_fields.album_type); - 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 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 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!( @@ -1385,7 +1387,14 @@ fn tag_file( } fn validate_file(path: &Path) -> Result<(), Error> { - Command::new("ogginfo").arg(path).output()?; + 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(), + )); + } + } Ok(()) } diff --git a/crates/downloader/src/main.rs b/crates/downloader/src/main.rs index a7c2040..0cf0a85 100644 --- a/crates/downloader/src/main.rs +++ b/crates/downloader/src/main.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, num::NonZeroU32, path::PathBuf, sync::{ @@ -10,7 +11,7 @@ use std::{ use clap::{Parser, Subcommand}; use futures_util::{stream, StreamExt, TryStreamExt}; -use indicatif::MultiProgress; +use indicatif::{MultiProgress, ProgressBar}; use spotifyio::{ model::{AlbumId, ArtistId, Id, IdConstruct, SearchResult, SearchType, TrackId}, ApplicationCache, AuthCredentials, AuthenticationType, Quota, Session, SessionConfig, @@ -61,6 +62,9 @@ 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, @@ -83,7 +87,9 @@ enum Commands { /// Migrate database Migrate, /// Add a new artist - AddArtist { artist: Vec }, + AddArtist { + artist: Vec, + }, /// Download new music DlNew { #[clap(long, default_value = "12h")] @@ -94,15 +100,20 @@ 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] @@ -186,7 +197,8 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { quota, db_path: cli.db, base_dir: cli.base_dir, - progress: Some(multi), + progress: Some(multi.clone()), + apply_tags: !cli.no_tags, format_m4a: cli.m4a, widevine_device: cli.wvd, ..Default::default() @@ -338,7 +350,64 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { )); } } - _ => unreachable!(), + 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!(), } dl.shutdown().await; Ok(()) diff --git a/crates/spotifyio/Cargo.toml b/crates/spotifyio/Cargo.toml index 6ee0375..447125b 100644 --- a/crates/spotifyio/Cargo.toml +++ b/crates/spotifyio/Cargo.toml @@ -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.7", default-features = false, features = [ +governor = { version = "0.8", default-features = false, features = [ "std", "quanta", "jitter", diff --git a/crates/spotifyio/src/cdn_url.rs b/crates/spotifyio/src/cdn_url.rs index 7b13de0..f115e08 100644 --- a/crates/spotifyio/src/cdn_url.rs +++ b/crates/spotifyio/src/cdn_url.rs @@ -77,7 +77,7 @@ impl CdnUrl { Ok(cdn_url) } - pub fn try_get_url(&self) -> Result<&str, Error> { + pub fn try_get_url(&self) -> Result<&MaybeExpiringUrl, Error> { if self.urls.is_empty() { return Err(CdnUrlError::Unresolved.into()); } @@ -89,7 +89,7 @@ impl CdnUrl { }); if let Some(url) = url { - Ok(&url.0) + Ok(url) } else { Err(CdnUrlError::Expired.into()) } diff --git a/crates/spotifyio/src/connection/handshake.rs b/crates/spotifyio/src/connection/handshake.rs index 259166d..04a12b8 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, 'b, T: AsyncRead + Unpin>( - connection: &'a mut T, +async fn read_into_accumulator<'a, T: AsyncRead + Unpin>( + connection: &mut T, size: usize, - acc: &'b mut Vec, -) -> io::Result<&'b mut [u8]> { + acc: &'a mut Vec, +) -> io::Result<&'a mut [u8]> { let offset = acc.len(); acc.resize(offset + size, 0); diff --git a/crates/spotifyio/src/gql_model.rs b/crates/spotifyio/src/gql_model.rs index d9fcde6..35292e9 100644 --- a/crates/spotifyio/src/gql_model.rs +++ b/crates/spotifyio/src/gql_model.rs @@ -547,6 +547,7 @@ 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, diff --git a/crates/spotifyio/src/lib.rs b/crates/spotifyio/src/lib.rs index 741419a..f573bcb 100644 --- a/crates/spotifyio/src/lib.rs +++ b/crates/spotifyio/src/lib.rs @@ -36,7 +36,7 @@ use spclient::SpClient; pub use audio_key::{AudioKey, AudioKeyError}; pub use authentication::AuthCredentials; pub use cache::{ApplicationCache, SessionCache}; -pub use cdn_url::{CdnUrl, CdnUrlError}; +pub use cdn_url::{CdnUrl, CdnUrlError, MaybeExpiringUrl}; pub use error::{Error, ErrorKind, NotModifiedRes}; pub use governor::{Jitter, Quota}; pub use normalisation::NormalisationData; @@ -181,6 +181,10 @@ 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, @@ -188,6 +192,13 @@ 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(); diff --git a/crates/spotifyio/src/normalisation.rs b/crates/spotifyio/src/normalisation.rs index 535489d..f65fbb3 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_file seeking to {} but position is now {}", + "NormalisationData::parse_from_ogg seeking to {} but position is now {}", SPOTIFY_NORMALIZATION_HEADER_START_OFFSET, newpos ))); } diff --git a/crates/spotifyio/src/util.rs b/crates/spotifyio/src/util.rs index 430691c..71d430c 100644 --- a/crates/spotifyio/src/util.rs +++ b/crates/spotifyio/src/util.rs @@ -89,6 +89,25 @@ 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