Compare commits

...

10 commits

48 changed files with 2561 additions and 714 deletions

View file

@ -14,6 +14,6 @@ resolver = "2"
protobuf = "3.5" protobuf = "3.5"
# WS crates # 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-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" }

View file

@ -20,5 +20,7 @@ tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
futures-util = "0.3" futures-util = "0.3"
widevine = "0.1" widevine = "0.1"
console = "0.15" console = "0.15"
serde_json = "1"
protobuf-json-mapping = "3"
spotifyio = {workspace = true, features = ["oauth"]} spotifyio = {workspace = true, features = ["oauth"]}

View file

@ -5,8 +5,10 @@ use clap::{Parser, Subcommand};
use futures_util::{stream, StreamExt, TryStreamExt}; use futures_util::{stream, StreamExt, TryStreamExt};
use path_macro::path; use path_macro::path;
use spotifyio::{ use spotifyio::{
pb::AudioFileFormat, ApplicationCache, AuthCredentials, AuthenticationType, CdnUrl, FileId, model::{FileId, Id, IdConstruct, IdError, PlayableId, TrackId, UserId},
PoolConfig, Session, SessionConfig, SpotifyId, SpotifyIoPool, pb::AudioFileFormat,
ApplicationCache, AuthCredentials, AuthenticationType, CdnUrl, PoolConfig, Session,
SessionConfig, SpotifyIoPool,
}; };
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@ -17,6 +19,11 @@ use widevine::{Cdm, Device, Pssh};
struct Cli { struct Cli {
#[clap(subcommand)] #[clap(subcommand)]
command: Commands, command: Commands,
#[clap(long, default_value = "en")]
lang: String,
/// Path to Spotify account cache
#[clap(long)]
cache: Option<PathBuf>,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@ -32,6 +39,10 @@ enum Commands {
Accounts, Accounts,
Track { Track {
id: String, id: String,
#[clap(long)]
key: bool,
#[clap(long)]
web: bool,
}, },
Album { Album {
id: String, id: String,
@ -39,6 +50,12 @@ enum Commands {
Artist { Artist {
id: String, id: String,
}, },
Playlist {
id: String,
},
User {
id: String,
},
Prerelease { Prerelease {
id: String, id: String,
}, },
@ -49,11 +66,21 @@ enum Commands {
id: String, id: String,
}, },
Upkeep, Upkeep,
AudioFeatures {
id: String,
},
AudioAnalysis {
id: String,
},
Convertid {
b64: String,
},
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Error> { async fn main() -> Result<(), Error> {
tracing_subscriber::fmt::SubscriberBuilder::default() tracing_subscriber::fmt::SubscriberBuilder::default()
.with_writer(std::io::stderr)
.with_env_filter( .with_env_filter(
EnvFilter::builder() EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into()) .with_default_directive(LevelFilter::INFO.into())
@ -61,16 +88,22 @@ async fn main() -> Result<(), Error> {
.unwrap(), .unwrap(),
) )
.init(); .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 app_cache = ApplicationCache::new(path);
let cli = Cli::parse(); let scfg = SessionConfig {
language: cli.lang,
..Default::default()
};
match cli.command { match cli.command {
Commands::Login => { Commands::Login => {
let token = spotifyio::oauth::get_access_token(None).await?; let token = spotifyio::oauth::get_access_token(None).await?;
let cache = spotifyio::oauth::new_session(&app_cache, &token.access_token).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 session
.connect(AuthCredentials { .connect(AuthCredentials {
user_id: None, user_id: None,
@ -100,99 +133,139 @@ async fn main() -> Result<(), Error> {
); );
} }
} }
Commands::Track { id } => { Commands::Track { id, key, web } => {
let pool = SpotifyIoPool::from_app_cache( let pool = SpotifyIoPool::from_app_cache(
app_cache, app_cache,
&SessionConfig::default(), &scfg,
&PoolConfig { &PoolConfig {
max_sessions: 1, max_sessions: 1,
..Default::default() ..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?; if web {
dbg!(&track); let track = pool.spclient()?.web_track(spotify_id, None).await?;
println!("{}", serde_json::to_string(&track).unwrap());
} else {
let track_et = pool.spclient()?.pb_track(spotify_id, None).await?;
tracing::info!("ETag: {}", track_et.etag.unwrap_or_default());
let track = track_et.data;
println!(
"{}",
protobuf_json_mapping::print_to_string(&track).unwrap()
);
let spotify_id = SpotifyId::from_raw(track.gid()).unwrap(); if key {
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 { for file in &track.file {
println!("{} {:?}", FileId::from_raw(file.file_id())?, file.format()); 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(&spotify_id, &file_id)
.await?;
println!("Audio key: {}", data_encoding::HEXLOWER.encode(&key.0));
} }
Commands::Album { id } => { Commands::Album { id } => {
let pool = SpotifyIoPool::from_app_cache( let pool = SpotifyIoPool::from_app_cache(
app_cache, app_cache,
&SessionConfig::default(), &scfg,
&PoolConfig { &PoolConfig {
max_sessions: 1, max_sessions: 1,
..Default::default() ..Default::default()
}, },
)?; )?;
let id = SpotifyId::from_base62(&id)?; let id = parse_idstr(&id)?;
let album = pool.spclient()?.pb_album(&id).await?; let album_et = pool.spclient()?.pb_album(id, None).await?;
dbg!(&album); tracing::info!("ETag: {}", album_et.etag.unwrap_or_default());
println!(
for disc in album.disc { "{}",
for track in disc.track { protobuf_json_mapping::print_to_string(&album_et.data).unwrap()
println!("{}", SpotifyId::from_raw(track.gid())?.to_base62()) );
}
}
} }
Commands::Artist { id } => { Commands::Artist { id } => {
let pool = SpotifyIoPool::from_app_cache( let pool = SpotifyIoPool::from_app_cache(
app_cache, app_cache,
&SessionConfig::default(), &scfg,
&PoolConfig { &PoolConfig {
max_sessions: 1, max_sessions: 1,
..Default::default() ..Default::default()
}, },
)?; )?;
let id = SpotifyId::from_base62(&id)?; let id = parse_idstr(&id)?;
let artist = pool.spclient()?.pb_artist(&id).await?; let artist_et = pool.spclient()?.pb_artist(id, None).await?;
tracing::info!("ETag: {}", artist_et.etag.unwrap_or_default());
println!(
"{}",
protobuf_json_mapping::print_to_string(&artist_et.data).unwrap()
);
}
Commands::Playlist { id } => {
let pool = SpotifyIoPool::from_app_cache(
app_cache,
&scfg,
&PoolConfig {
max_sessions: 1,
..Default::default()
},
)?;
dbg!(&artist); let id = parse_idstr(&id)?;
let playlist = pool.spclient()?.pb_playlist(id, None).await?;
tracing::info!("ETag: {}", playlist.etag.unwrap_or_default());
dbg!(&playlist.data);
}
Commands::User { id } => {
let pool = SpotifyIoPool::from_app_cache(
app_cache,
&scfg,
&PoolConfig {
max_sessions: 1,
..Default::default()
},
)?;
let id = UserId::from_id(&id)?;
let user = pool.spclient()?.get_user_profile(id, 20).await?;
println!("{}", serde_json::to_string(&user).unwrap());
} }
Commands::Prerelease { id } => { Commands::Prerelease { id } => {
let pool = SpotifyIoPool::from_app_cache( let pool = SpotifyIoPool::from_app_cache(
app_cache, app_cache,
&SessionConfig::default(), &scfg,
&PoolConfig { &PoolConfig {
max_sessions: 1, max_sessions: 1,
..Default::default() ..Default::default()
}, },
)?; )?;
let id = SpotifyId::from_base62(&id)?; let id = parse_idstr(&id)?;
let prerelease = pool.spclient()?.gql_prerelease(&id).await?; let prerelease = pool.spclient()?.gql_prerelease(id).await?;
dbg!(&prerelease); println!("{}", serde_json::to_string(&prerelease).unwrap());
} }
Commands::Ratelimit { ids } => { Commands::Ratelimit { ids } => {
let pool = SpotifyIoPool::from_app_cache( let pool = SpotifyIoPool::from_app_cache(app_cache, &scfg, &PoolConfig::default())?;
app_cache, let txt = std::fs::read_to_string(ids)?;
&SessionConfig::default(), let ids = txt
&PoolConfig::default(),
)?;
let ids = std::fs::read_to_string(ids)?
.lines() .lines()
.filter_map(|line| SpotifyId::from_base62(line).ok()) .filter_map(|line| parse_idstr(line).ok())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let client = pool.spclient()?; let client = pool.spclient()?;
@ -202,9 +275,9 @@ async fn main() -> Result<(), Error> {
// let pool = pool.clone(); // let pool = pool.clone();
let client = client.clone(); let client = client.clone();
async move { 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 { 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!( tracing::info!(
"Prerelease: [{}] {}", "Prerelease: [{}] {}",
prerelease.uri, prerelease.uri,
@ -244,14 +317,14 @@ async fn main() -> Result<(), Error> {
Commands::Widevine { id } => { Commands::Widevine { id } => {
let pool = SpotifyIoPool::from_app_cache( let pool = SpotifyIoPool::from_app_cache(
app_cache, app_cache,
&SessionConfig::default(), &scfg,
&PoolConfig { &PoolConfig {
max_sessions: 1, max_sessions: 1,
..Default::default() ..Default::default()
}, },
)?; )?;
let spid = SpotifyId::from_base62(&id)?; let spid = parse_idstr(&id)?;
let track = pool.spclient()?.pb_track(&spid).await?; let track = pool.spclient()?.pb_track(spid, None).await?.data;
let mp4_file = track let mp4_file = track
.file .file
.iter() .iter()
@ -265,7 +338,7 @@ async fn main() -> Result<(), Error> {
// let test_key = pool.audio_key().await?.request(&spid, &file_id).await?; // 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 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 cdm = Cdm::new(device);
let pssh = Pssh::from_b64(&seektable.pssh)?; let pssh = Pssh::from_b64(&seektable.pssh)?;
@ -280,13 +353,13 @@ async fn main() -> Result<(), Error> {
let keys = lic.get_keys(&license_message)?; let keys = lic.get_keys(&license_message)?;
println!("[{}] {}", id, track.name()); println!("[{}] {}", id, track.name());
println!("{url}"); println!("{}", url.0);
dbg!(keys); dbg!(keys);
} }
Commands::Upkeep => { Commands::Upkeep => {
let pool = SpotifyIoPool::from_app_cache( let pool = SpotifyIoPool::from_app_cache(
app_cache, app_cache,
&SessionConfig::default(), &scfg,
&PoolConfig { &PoolConfig {
max_sessions: 1, max_sessions: 1,
..Default::default() ..Default::default()
@ -295,13 +368,16 @@ async fn main() -> Result<(), Error> {
pool.connect().await.unwrap(); pool.connect().await.unwrap();
let stdout = console::Term::buffered_stdout(); let stdout = console::Term::buffered_stdout();
let spotify_id = SpotifyId::from_base62("4AY7RuEn7nmnxtvya5ygCt").unwrap(); let spotify_id = parse_idstr::<TrackId>("4AY7RuEn7nmnxtvya5ygCt").unwrap();
let file_id = FileId::from_base16("560ad6f0d543aa20e7851f891b4e40ba47d54b54").unwrap(); let file_id = FileId::from_base16("560ad6f0d543aa20e7851f891b4e40ba47d54b54").unwrap();
loop { loop {
_ = stdout.read_char(); _ = stdout.read_char();
match pool.audio_key_timeout(Duration::from_secs(10)).await { 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) => { Ok(key) => {
println!("Audio key: {}", data_encoding::HEXLOWER.encode(&key.0)) println!("Audio key: {}", data_encoding::HEXLOWER.encode(&key.0))
} }
@ -311,6 +387,49 @@ 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::<TrackId>(&b64)?;
println!("{id}");
}
} }
Ok(()) Ok(())
} }
fn parse_idstr<'a, T: IdConstruct<'a>>(s: &'a str) -> Result<T, IdError> {
if s.ends_with("==") {
let bytes = data_encoding::BASE64
.decode(s.as_bytes())
.map_err(|_| IdError::InvalidId)?;
T::from_raw(&bytes)
} else {
T::from_id_or_uri(s)
}
}

View file

@ -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"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "insert into albums (id, name) values ($1, $2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "17be8832946ce5585d93cb4e76d597624a633817d76ac34dd6378e7210b644e5"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "update tracks set not_available=true where id=?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "84a7b6606d0e2f04c40c7bae39cff81e77b5871b5bf74bc9c6faf7658a946980"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -1,7 +1,7 @@
[package] [package]
name = "spotifyio-downloader" name = "spotifyio-downloader"
description = "CLI for downloading music from Spotify" description = "CLI for downloading music from Spotify"
version = "0.1.0" version = "0.3.1"
edition.workspace = true edition.workspace = true
authors.workspace = true authors.workspace = true
license.workspace = true license.workspace = true
@ -47,7 +47,7 @@ sqlx = { version = "0.8.0", features = [
aes = "0.8" aes = "0.8"
ctr = "0.9" ctr = "0.9"
filenamify = "0.1.0" filenamify = "0.1.0"
lofty = "0.21.0" lofty = "0.22.0"
walkdir = "2.3.3" walkdir = "2.3.3"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@ -58,7 +58,7 @@ time = { version = "0.3.21", features = [
"parsing", "parsing",
] } ] }
once_cell = "1.0" once_cell = "1.0"
itertools = "0.13.0" itertools = "0.14.0"
reqwest = { version = "0.12.0", features = [ reqwest = { version = "0.12.0", features = [
"stream", "stream",
], default-features = false } ], default-features = false }

View file

@ -0,0 +1 @@
alter table artists add column etag TEXT;

View file

@ -0,0 +1 @@
alter table tracks add column audio_features TEXT;

View file

@ -27,9 +27,10 @@ use once_cell::sync::Lazy;
use path_macro::path; use path_macro::path;
use serde::Serialize; use serde::Serialize;
use spotifyio::{ use spotifyio::{
pb::AudioFileFormat, AudioKey, CdnUrl, Error as SpotifyError, FileId, IdError, model::{AlbumId, ArtistId, EpisodeId, FileId, Id, IdConstruct, IdError, PlayableId, TrackId},
NormalisationData, PoolConfig, PoolError, Quota, Session, SessionConfig, SpotifyId, pb::AudioFileFormat,
SpotifyIoPool, SpotifyItemType, AudioKey, CdnUrl, Error as SpotifyError, NormalisationData, NotModifiedRes, PoolConfig,
PoolError, Quota, Session, SessionConfig, SpotifyIoPool,
}; };
use spotifyio_protocol::metadata::{Album, Availability, Restriction, Track}; use spotifyio_protocol::metadata::{Album, Availability, Restriction, Track};
use sqlx::{sqlite::SqliteConnectOptions, ConnectOptions, SqlitePool}; use sqlx::{sqlite::SqliteConnectOptions, ConnectOptions, SqlitePool};
@ -99,6 +100,7 @@ pub struct SpotifyDownloaderInner {
multi: Option<MultiProgress>, multi: Option<MultiProgress>,
mp4decryptor: Option<Mp4Decryptor>, mp4decryptor: Option<Mp4Decryptor>,
cdm: Option<Cdm>, cdm: Option<Cdm>,
apply_tags: bool,
format_m4a: bool, format_m4a: bool,
widevine_cert: RwLock<Option<(ServiceCertificate, Instant)>>, widevine_cert: RwLock<Option<(ServiceCertificate, Instant)>>,
} }
@ -270,6 +272,7 @@ pub struct SpotifyDownloaderConfig {
pub progress: Option<MultiProgress>, pub progress: Option<MultiProgress>,
pub widevine_device: Option<PathBuf>, pub widevine_device: Option<PathBuf>,
pub mp4decryptor: Option<Mp4Decryptor>, pub mp4decryptor: Option<Mp4Decryptor>,
pub apply_tags: bool,
pub format_m4a: bool, pub format_m4a: bool,
} }
@ -284,6 +287,7 @@ impl Default for SpotifyDownloaderConfig {
progress: None, progress: None,
widevine_device: None, widevine_device: None,
mp4decryptor: Mp4Decryptor::from_env(), mp4decryptor: Mp4Decryptor::from_env(),
apply_tags: true,
format_m4a: false, format_m4a: false,
} }
} }
@ -320,6 +324,7 @@ impl SpotifyDownloader {
multi: cfg.progress, multi: cfg.progress,
cdm, cdm,
mp4decryptor: cfg.mp4decryptor, mp4decryptor: cfg.mp4decryptor,
apply_tags: cfg.apply_tags,
format_m4a: cfg.format_m4a, format_m4a: cfg.format_m4a,
widevine_cert: RwLock::new(None), widevine_cert: RwLock::new(None),
} }
@ -383,17 +388,17 @@ impl SpotifyDownloader {
Ok(cert) Ok(cert)
} }
async fn get_audio_item(&self, spotify_id: &SpotifyId) -> Result<AudioItem, Error> { async fn get_audio_item(&self, spotify_id: PlayableId<'_>) -> Result<AudioItem, Error> {
let session = self.i.sp.session()?; let session = self.i.sp.session()?;
match spotify_id.item_type { match spotify_id {
SpotifyItemType::Track => { PlayableId::Track(id) => {
let track = session.spclient().pb_track(spotify_id).await?; let track = session.spclient().pb_track(id, None).await?.data;
map_track(session, track) map_track(session, track)
} }
SpotifyItemType::Episode => { PlayableId::Episode(id) => {
let episode = session.spclient().pb_episode(spotify_id).await?; let episode = session.spclient().pb_episode(id, None).await?.data;
Ok(AudioItem { Ok(AudioItem {
track_id: SpotifyId::from_raw(episode.gid())?.episode(), track_id: PlayableId::Episode(EpisodeId::from_raw(episode.gid())?),
actual_id: None, actual_id: None,
files: AudioFiles::from(episode.audio.as_slice()), files: AudioFiles::from(episode.audio.as_slice()),
covers: get_covers(episode.cover_image.image.iter()), covers: get_covers(episode.cover_image.image.iter()),
@ -415,16 +420,15 @@ impl SpotifyDownloader {
}), }),
}) })
} }
_ => Err(MetadataError::NonPlayable.into()),
} }
} }
async fn load_track( async fn load_track(
&self, &self,
spotify_id: &SpotifyId, spotify_id: PlayableId<'_>,
) -> Result<(AudioItem, FileId, AudioFileFormat), Error> { ) -> Result<(AudioItem, FileId, AudioFileFormat), Error> {
let audio_item = self.get_audio_item(spotify_id).await?; 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!( tracing::info!(
"Loading <{}> with Spotify URI <{}>", "Loading <{}> with Spotify URI <{}>",
@ -451,27 +455,23 @@ impl SpotifyDownloader {
formats_default.as_slice() formats_default.as_slice()
}; };
let (format, file_id) = let (format, file_id) = formats
match formats .iter()
.iter() .find_map(|format| match audio_item.files.0.get(format) {
.find_map(|format| match audio_item.files.0.get(format) { Some(&file_id) => Some((*format, file_id)),
Some(&file_id) => Some((*format, file_id)), _ => None,
_ => None, })
}) { .ok_or_else(|| Error::Unavailable("Format unavailable".to_owned()))?;
Some(t) => t,
None => {
return Err(Error::Unavailable("Format unavailable".to_owned()));
}
};
Ok((audio_item, file_id, format)) Ok((audio_item, file_id, format))
} }
async fn get_file_key_clearkey( async fn get_file_key_clearkey(
&self, &self,
spotify_id: &SpotifyId, spotify_id: PlayableId<'_>,
file_id: FileId, file_id: FileId,
) -> Result<AudioKey, Error> { ) -> Result<AudioKey, Error> {
tracing::debug!("getting clearkey for {file_id}");
let key = self let key = self
.i .i
.sp .sp
@ -485,6 +485,7 @@ impl SpotifyDownloader {
} }
async fn get_file_key_widevine(&self, file_id: FileId) -> Result<widevine::Key, Error> { async fn get_file_key_widevine(&self, file_id: FileId) -> Result<widevine::Key, Error> {
tracing::debug!("getting widevine key for {file_id}");
let cdm = self let cdm = self
.i .i
.cdm .cdm
@ -494,6 +495,7 @@ impl SpotifyDownloader {
let seektable = sp.get_seektable(&file_id).await?; let seektable = sp.get_seektable(&file_id).await?;
let cert = self.get_widevine_cert().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 pssh = Pssh::from_b64(&seektable.pssh)?;
let request = cdm let request = cdm
.open() .open()
@ -510,7 +512,7 @@ impl SpotifyDownloader {
async fn get_file_key( async fn get_file_key(
&self, &self,
spotify_id: &SpotifyId, spotify_id: PlayableId<'_>,
file_id: FileId, file_id: FileId,
format: AudioFileFormat, format: AudioFileFormat,
) -> Result<AudioKeyVariant, Error> { ) -> Result<AudioKeyVariant, Error> {
@ -525,8 +527,8 @@ impl SpotifyDownloader {
} }
} }
async fn get_genre(&self, artist_id: &SpotifyId) -> Result<Option<String>, Error> { async fn get_genre(&self, artist_id: ArtistId<'_>) -> Result<Option<String>, Error> {
let aid_str = artist_id.to_base62().into_owned(); let aid_str = artist_id.id().to_owned();
{ {
let gc = GENRE_CACHE.read().unwrap(); let gc = GENRE_CACHE.read().unwrap();
@ -545,7 +547,7 @@ impl SpotifyDownloader {
let got = if let Some(res) = res { let got = if let Some(res) = res {
res.genre.map(|s| capitalize(&s)) res.genre.map(|s| capitalize(&s))
} else { } 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)) artist.genre.first().map(|s| capitalize(s))
}; };
@ -556,7 +558,7 @@ impl SpotifyDownloader {
/// Spotify does not output the number of discs of an album in its track metadata. /// 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. /// 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. /// 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<u32, Error> { async fn get_n_discs(&self, album_id: AlbumId<'_>) -> Result<u32, Error> {
let aid_str = album_id.to_string(); let aid_str = album_id.to_string();
{ {
@ -575,32 +577,29 @@ impl SpotifyDownloader {
let final_res = if let Some(res) = res { let final_res = if let Some(res) = res {
res.n_discs.unwrap() as u32 res.n_discs.unwrap() as u32
} else { } 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) album_n_discs(&album)
}; };
N_DISCS_CACHE.write().unwrap().insert(aid_str, final_res); N_DISCS_CACHE.write().unwrap().insert(aid_str, final_res);
Ok(final_res) Ok(final_res)
} }
async fn mark_track_unavailable(&self, track_id: &SpotifyId) -> Result<(), Error> { async fn mark_track_unavailable(&self, track_id: TrackId<'_>) -> Result<(), Error> {
let id = track_id.to_base62(); let id = track_id.id();
sqlx::query!("update tracks set not_available=true where id=?", id) sqlx::query!("update tracks set not_available=true where id=?", id)
.execute(&self.i.pool) .execute(&self.i.pool)
.await?; .await?;
Ok(()) Ok(())
} }
async fn find_available_alternative( fn find_available_alternative(&self, mut audio_item: AudioItem) -> Result<AudioItem, Error> {
&self,
mut audio_item: AudioItem,
) -> Result<AudioItem, Error> {
if audio_item.availability.is_ok() && !audio_item.files.0.is_empty() { if audio_item.availability.is_ok() && !audio_item.files.0.is_empty() {
return Ok(audio_item); return Ok(audio_item);
} }
for alt in audio_item.alternatives { for alt in audio_item.alternatives {
if alt.availability.is_ok() { 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.alternatives = Vec::new();
audio_item.availability = alt.availability; audio_item.availability = alt.availability;
audio_item.files = alt.files; audio_item.files = alt.files;
@ -689,10 +688,11 @@ impl SpotifyDownloader {
#[tracing::instrument(level = "error", skip(self, force))] #[tracing::instrument(level = "error", skip(self, force))]
pub async fn download_track( pub async fn download_track(
&self, &self,
track_id: &SpotifyId, track_id: TrackId<'_>,
force: bool, force: bool,
) -> Result<Option<AudioItem>, Error> { ) -> Result<Option<AudioItem>, 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 // Check if track was already downloaded
let row = sqlx::query!("select downloaded from tracks where id=$1", input_id_str) let row = sqlx::query!("select downloaded from tracks where id=$1", input_id_str)
.fetch_optional(&self.i.pool) .fetch_optional(&self.i.pool)
@ -704,12 +704,12 @@ impl SpotifyDownloader {
// Get stream from Spotify // Get stream from Spotify
let sess = self.i.sp.session()?; 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) let cdn_url = CdnUrl::new(file_id)
.resolve_audio(self.i.sp.session()?) .resolve_audio(self.i.sp.session()?)
.await?; .await?;
let audio_url = cdn_url.try_get_url()?; let audio_url = &cdn_url.try_get_url()?.0;
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 { let track_fields = if let UniqueFields::Track(f) = &sp_item.unique_fields {
f f
@ -718,7 +718,7 @@ impl SpotifyDownloader {
"podcast episodes are not supported", "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 title = &sp_item.name;
let artist = track_fields let artist = track_fields
@ -735,8 +735,8 @@ impl SpotifyDownloader {
.max_by_key(|img| img.width) .max_by_key(|img| img.width)
.ok_or(MetadataError::Missing("album cover"))?; .ok_or(MetadataError::Missing("album cover"))?;
let artists_json = serde_json::to_string(&track_fields.artists).unwrap(); let artists_json = serde_json::to_string(&track_fields.artists).unwrap();
let genre = self.get_genre(&SpotifyId::from_base62(&artist.id)?).await?; let genre = self.get_genre(ArtistId::from_id(&artist.id)?).await?;
let album_id_str = track_fields.album_id.to_base62(); let album_id_str = track_fields.album_id.id();
let artist_dir = path!(self.i.base_dir / better_filenamify(&album_artist.name, None)); let artist_dir = path!(self.i.base_dir / better_filenamify(&album_artist.name, None));
if !artist_dir.is_dir() { if !artist_dir.is_dir() {
@ -744,8 +744,9 @@ impl SpotifyDownloader {
let img_path = path!(artist_dir / "artist.jpg"); let img_path = path!(artist_dir / "artist.jpg");
let artist = sess let artist = sess
.spclient() .spclient()
.pb_artist(&SpotifyId::from_base62(&album_artist.id)?) .pb_artist(ArtistId::from_id(&album_artist.id)?, None)
.await?; .await?
.data;
let image = artist let image = artist
.portrait_group .portrait_group
@ -773,7 +774,7 @@ impl SpotifyDownloader {
let album_folder = album_folder( let album_folder = album_folder(
&album_artist.name, &album_artist.name,
&track_fields.album_name, &track_fields.album_name,
&album_id_str, album_id_str,
track_fields.disc_number, track_fields.disc_number,
n_discs, n_discs,
); );
@ -786,44 +787,6 @@ impl SpotifyDownloader {
std::fs::create_dir_all(&album_dir)?; std::fs::create_dir_all(&album_dir)?;
// Download the file // 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::<Vec<_>>());
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 (norm_data, file_size) = self.download_audio_file(audio_url, &key, &tpath).await?;
let file_size = file_size as i64; let file_size = file_size as i64;
@ -837,37 +800,36 @@ impl SpotifyDownloader {
} }
} }
let track_id_str = sp_item let track_id_str = sp_item.actual_id.as_ref().unwrap_or(&sp_item.track_id).id();
.actual_id
.as_ref()
.unwrap_or(&sp_item.track_id)
.to_base62();
let audio_format_str = format!("{:?}", audio_format); let audio_format_str = format!("{:?}", audio_format);
let key_str = key.to_string(); let key_str = key.to_string();
let release_date_str = sp_item.release_date.as_ref().and_then(date_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); let album_type_str = album_type_str(track_fields.album_type);
if let Err(e) = tag_file( if self.i.apply_tags {
&tpath, if let Err(e) = tag_file(
&track_id_str, &tpath,
title, track_id_str,
&album_artist.name, title,
&track_fields.album_name, &album_artist.name,
track_fields.number, &track_fields.album_name,
track_fields.disc_number, track_fields.number,
&artist.name, track_fields.disc_number,
&artists_json, &artist.name,
&album_id_str, &artists_json,
Some(&album_artist.id), album_id_str,
release_date_str.as_deref(), Some(&album_artist.id),
genre.as_deref(), release_date_str.as_deref(),
norm_data, genre.as_deref(),
) { norm_data,
tracing::error!("error saving tags: {}", e); ) {
tracing::error!("error saving tags: {}", e);
}
} }
if validate_file(&tpath).is_err() { 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())); return Err(Error::InvalidFile(tpath.to_string_lossy().to_string()));
} }
tracing::info!( tracing::info!(
@ -949,9 +911,9 @@ impl SpotifyDownloader {
/// This is the case if the download is either /// This is the case if the download is either
/// - successful /// - successful
/// - unavailable /// - 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 { 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) => { Ok(sp_item) => {
if let Some(sp_item) = &sp_item { if let Some(sp_item) = &sp_item {
if let UniqueFields::Track(sp_track) = &sp_item.unique_fields { if let UniqueFields::Track(sp_track) = &sp_item.unique_fields {
@ -988,9 +950,25 @@ impl SpotifyDownloader {
false false
} }
pub async fn download_artist(&self, artist_id: &SpotifyId) -> Result<bool, Error> { pub async fn download_artist(
let artist_id_str = artist_id.to_base62(); &self,
let artist = self.i.sp.spclient()?.pb_artist(artist_id).await?; artist_id: ArtistId<'_>,
if_none_match: Option<&str>,
) -> Result<bool, Error> {
let artist_id_str = artist_id.id();
let artist_res = self
.i
.sp
.spclient()?
.pb_artist(artist_id.as_ref(), if_none_match)
.await
.into_option()?;
let artist_et = match artist_res {
Some(artist_et) => artist_et,
None => return Ok(true),
};
let artist = artist_et.data;
let artist_name = artist.name.unwrap_or_default(); let artist_name = artist.name.unwrap_or_default();
let album_ids = artist let album_ids = artist
.album_group .album_group
@ -998,7 +976,7 @@ impl SpotifyDownloader {
.chain(artist.single_group) .chain(artist.single_group)
.chain(artist.compilation_group) .chain(artist.compilation_group)
.filter_map(|b| b.album.into_iter().next()) .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::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
tracing::info!( tracing::info!(
@ -1010,15 +988,16 @@ impl SpotifyDownloader {
let mut success = true; let mut success = true;
for album_id in &album_ids { for album_id in &album_ids {
success &= self.download_album(album_id).await?; success &= self.download_album(album_id.as_ref()).await?;
} }
if success { if success {
let now = OffsetDateTime::now_utc().unix_timestamp(); let now = OffsetDateTime::now_utc().unix_timestamp();
let res = sqlx::query!( 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, artist_id_str,
now, now,
artist_et.etag,
) )
.execute(&self.i.pool) .execute(&self.i.pool)
.await; .await;
@ -1033,9 +1012,15 @@ impl SpotifyDownloader {
Ok(success) Ok(success)
} }
pub async fn download_album(&self, album_id: &SpotifyId) -> Result<bool, Error> { pub async fn download_album(&self, album_id: AlbumId<'_>) -> Result<bool, Error> {
let album_id_str = album_id.to_base62(); let album_id_str = album_id.id();
let album = self.i.sp.spclient()?.pb_album(album_id).await?; 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) let row = sqlx::query!(r#"select id from albums where id=$1"#, album_id_str)
.fetch_optional(&self.i.pool) .fetch_optional(&self.i.pool)
@ -1048,13 +1033,13 @@ impl SpotifyDownloader {
N_DISCS_CACHE N_DISCS_CACHE
.write() .write()
.unwrap() .unwrap()
.insert(album_id.to_base62().into_owned(), n_discs); .insert(album_id_str.to_owned(), n_discs);
let track_ids = album let track_ids = album
.disc .disc
.iter() .iter()
.flat_map(|d| &d.track) .flat_map(|d| &d.track)
.map(|t| SpotifyId::from_raw(t.gid()).map(SpotifyId::track)) .map(|t| TrackId::from_raw(t.gid()))
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
let success = Arc::new(AtomicBool::new(true)); let success = Arc::new(AtomicBool::new(true));
@ -1063,7 +1048,7 @@ impl SpotifyDownloader {
.for_each_concurrent(4, |id| { .for_each_concurrent(4, |id| {
let success = success.clone(); let success = success.clone();
async move { async move {
let ok = self.download_track_log(id, false).await; let ok = self.download_track_log(id.as_ref(), false).await;
if !ok { if !ok {
success.store(false, Ordering::SeqCst); success.store(false, Ordering::SeqCst);
} }
@ -1098,18 +1083,18 @@ impl SpotifyDownloader {
m.add(pb) m.add(pb)
}); });
sqlx::query!( 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 date_thr
) )
.fetch(&self.i.pool) .fetch(&self.i.pool)
.map_err(Error::from) .map_err(Error::from)
.try_for_each_concurrent(4, |x| { .try_for_each_concurrent(4, |row| {
if let Some(progress) = &progress { if let Some(progress) = &progress {
progress.inc(1); progress.inc(1);
} }
let artist_id = SpotifyId::from_base62(&x.id.unwrap()).unwrap().artist();
async move { async move {
self.download_artist(&artist_id).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(()) Ok(())
} }
}) })
@ -1186,7 +1171,7 @@ fn map_track(session: &Session, track: Track) -> Result<AudioItem, Error> {
.ok_or(MetadataError::Missing("album"))?; .ok_or(MetadataError::Missing("album"))?;
Ok(AudioItem { Ok(AudioItem {
track_id: SpotifyId::from_raw(track.gid())?.track(), track_id: PlayableId::Track(TrackId::from_raw(track.gid())?),
actual_id: None, actual_id: None,
files: AudioFiles::from(track.file.as_slice()), files: AudioFiles::from(track.file.as_slice()),
covers: get_covers(album.cover_group.image.iter().chain(&album.cover)), covers: get_covers(album.cover_group.image.iter().chain(&album.cover)),
@ -1206,7 +1191,7 @@ fn map_track(session: &Session, track: Track) -> Result<AudioItem, Error> {
.iter() .iter()
.map(|track| { .map(|track| {
Ok(AlternativeTrack { Ok(AlternativeTrack {
track_id: SpotifyId::from_raw(track.gid())?.track(), track_id: TrackId::from_raw(track.gid())?,
availability: availability( availability: availability(
session, session,
&track.availability, &track.availability,
@ -1218,7 +1203,7 @@ fn map_track(session: &Session, track: Track) -> Result<AudioItem, Error> {
}) })
.collect::<Result<Vec<_>, Error>>()?, .collect::<Result<Vec<_>, Error>>()?,
unique_fields: UniqueFields::Track(TrackUniqueFields { unique_fields: UniqueFields::Track(TrackUniqueFields {
album_id: SpotifyId::from_raw(album.gid())?.album(), album_id: AlbumId::from_raw(album.gid())?,
artists: track artists: track
.artist_with_role .artist_with_role
.into_iter() .into_iter()
@ -1402,7 +1387,14 @@ fn tag_file(
} }
fn validate_file(path: &Path) -> Result<(), Error> { 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(()) Ok(())
} }

View file

@ -1,4 +1,5 @@
use std::{ use std::{
collections::HashMap,
num::NonZeroU32, num::NonZeroU32,
path::PathBuf, path::PathBuf,
sync::{ sync::{
@ -10,11 +11,10 @@ use std::{
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use futures_util::{stream, StreamExt, TryStreamExt}; use futures_util::{stream, StreamExt, TryStreamExt};
use indicatif::MultiProgress; use indicatif::{MultiProgress, ProgressBar};
use reqwest::Url;
use spotifyio::{ use spotifyio::{
ApplicationCache, AuthCredentials, AuthenticationType, Quota, SearchResult, SearchType, model::{AlbumId, ArtistId, Id, IdConstruct, SearchResult, SearchType, TrackId},
Session, SessionConfig, SpotifyId, ApplicationCache, AuthCredentials, AuthenticationType, Quota, Session, SessionConfig,
}; };
use spotifyio_downloader::{Error, SpotifyDownloader, SpotifyDownloaderConfig}; use spotifyio_downloader::{Error, SpotifyDownloader, SpotifyDownloaderConfig};
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
@ -51,8 +51,8 @@ struct Cli {
#[clap(long, default_value = "spotifyio.json")] #[clap(long, default_value = "spotifyio.json")]
cache: PathBuf, cache: PathBuf,
/// Path to music library base directory /// Path to music library base directory
#[clap(long)] #[clap(long, default_value = "Downloads")]
base_dir: Option<PathBuf>, base_dir: PathBuf,
/// Path to SQLite database /// Path to SQLite database
#[clap(long, default_value = "tracks.db")] #[clap(long, default_value = "tracks.db")]
db: PathBuf, db: PathBuf,
@ -62,6 +62,9 @@ struct Cli {
/// Path to Widevine device file (from pywidevine) /// Path to Widevine device file (from pywidevine)
#[clap(long)] #[clap(long)]
wvd: Option<PathBuf>, wvd: Option<PathBuf>,
#[clap(long)]
/// Dont apply audio tags
no_tags: bool,
/// Download tracks in m4a format /// Download tracks in m4a format
#[clap(long)] #[clap(long)]
m4a: bool, m4a: bool,
@ -84,7 +87,9 @@ enum Commands {
/// Migrate database /// Migrate database
Migrate, Migrate,
/// Add a new artist /// Add a new artist
AddArtist { artist: Vec<String> }, AddArtist {
artist: Vec<String>,
},
/// Download new music /// Download new music
DlNew { DlNew {
#[clap(long, default_value = "12h")] #[clap(long, default_value = "12h")]
@ -95,15 +100,20 @@ enum Commands {
navidrome_url: Option<String>, navidrome_url: Option<String>,
}, },
/// Download all albums of an artist /// Download all albums of an artist
DlArtist { artist: Vec<String> }, DlArtist {
artist: Vec<String>,
},
/// Download an album /// Download an album
DlAlbum { album: Vec<String> }, DlAlbum {
album: Vec<String>,
},
/// Download a track /// Download a track
DlTrack { DlTrack {
track: Vec<String>, track: Vec<String>,
#[clap(long)] #[clap(long)]
force: bool, force: bool,
}, },
ScrapeAudioFeatures,
} }
#[tokio::main] #[tokio::main]
@ -178,9 +188,6 @@ async fn account_mgmt(cli: Cli) -> Result<(), Error> {
} }
async fn download(cli: Cli, multi: MultiProgress) -> 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 { let quota = match cli.quota {
Some(q) => parse_quota(&q)?, Some(q) => parse_quota(&q)?,
None => Quota::per_minute(NonZeroU32::new(2).unwrap()), None => Quota::per_minute(NonZeroU32::new(2).unwrap()),
@ -189,8 +196,9 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
cache_path: cli.cache, cache_path: cli.cache,
quota, quota,
db_path: cli.db, db_path: cli.db,
base_dir, base_dir: cli.base_dir,
progress: Some(multi), progress: Some(multi.clone()),
apply_tags: !cli.no_tags,
format_m4a: cli.m4a, format_m4a: cli.m4a,
widevine_device: cli.wvd, widevine_device: cli.wvd,
..Default::default() ..Default::default()
@ -201,15 +209,15 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
dl.migrate().await?; dl.migrate().await?;
} }
Commands::AddArtist { artist } => { Commands::AddArtist { artist } => {
let parsed = parse_url_ids(&artist); let parsed = parse_url_ids::<ArtistId>(artist.iter());
let artists = if let Ok(parsed) = parsed { 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 got_artists
.values() .iter()
.map(|a| { .map(|a| {
Ok(spotifyio_downloader::model::ArtistItem { 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(), name: a.name().to_owned(),
}) })
}) })
@ -224,7 +232,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
if let SearchResult::Artists(res) = res { if let SearchResult::Artists(res) = res {
for (i, a) in res.items.iter().enumerate() { 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 stdin = std::io::stdin();
let mut buf = String::new(); let mut buf = String::new();
@ -238,7 +246,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
.get(i) .get(i)
.ok_or(Error::InvalidInput("invalid number"))?; .ok_or(Error::InvalidInput("invalid number"))?;
vec![spotifyio_downloader::model::ArtistItem { vec![spotifyio_downloader::model::ArtistItem {
id: a.id.to_base62().into_owned(), id: a.id.id().to_owned(),
name: a.name.to_owned(), name: a.name.to_owned(),
}] }]
} else { } else {
@ -276,7 +284,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
.await?; .await?;
} }
Commands::DlArtist { artist } => { Commands::DlArtist { artist } => {
let artist_ids = parse_url_ids(&artist)?; let artist_ids = parse_url_ids::<ArtistId>(&artist)?;
let success = Arc::new(AtomicBool::new(true)); let success = Arc::new(AtomicBool::new(true));
stream::iter(artist_ids) stream::iter(artist_ids)
.map(Ok::<_, Error>) .map(Ok::<_, Error>)
@ -284,7 +292,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
let dl = dl.clone(); let dl = dl.clone();
let success = success.clone(); let success = success.clone();
async move { async move {
let ok = dl.download_artist(&id.artist()).await?; let ok = dl.download_artist(id.as_ref(), None).await?;
if !ok { if !ok {
success.store(false, Ordering::SeqCst); success.store(false, Ordering::SeqCst);
} }
@ -299,7 +307,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
} }
} }
Commands::DlAlbum { album } => { Commands::DlAlbum { album } => {
let album_ids = parse_url_ids(&album)?; let album_ids = parse_url_ids::<AlbumId>(&album)?;
let success = Arc::new(AtomicBool::new(true)); let success = Arc::new(AtomicBool::new(true));
stream::iter(album_ids) stream::iter(album_ids)
.map(Ok::<_, Error>) .map(Ok::<_, Error>)
@ -307,7 +315,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
let dl = dl.clone(); let dl = dl.clone();
let success = success.clone(); let success = success.clone();
async move { async move {
let ok = dl.download_album(&id.album()).await?; let ok = dl.download_album(id).await?;
if !ok { if !ok {
success.store(false, Ordering::SeqCst); success.store(false, Ordering::SeqCst);
} }
@ -322,14 +330,14 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
} }
} }
Commands::DlTrack { track, force } => { Commands::DlTrack { track, force } => {
let track_ids = parse_url_ids(&track)?; let track_ids = parse_url_ids::<TrackId>(&track)?;
let success = Arc::new(AtomicBool::new(true)); let success = Arc::new(AtomicBool::new(true));
stream::iter(track_ids) stream::iter(track_ids)
.for_each_concurrent(8, |id| { .for_each_concurrent(8, |id| {
let dl = dl.clone(); let dl = dl.clone();
let success = success.clone(); let success = success.clone();
async move { 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 { if !ok {
success.store(false, Ordering::SeqCst); success.store(false, Ordering::SeqCst);
} }
@ -342,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::<HashMap<_, _>>();
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; dl.shutdown().await;
Ok(()) Ok(())
@ -369,20 +434,11 @@ fn parse_quota(s: &str) -> Result<Quota, Error> {
Ok(quota) Ok(quota)
} }
fn parse_url_or_id(s: &str) -> Result<SpotifyId, ()> { fn parse_url_ids<'a, T: IdConstruct<'a>>(
if let Ok(id) = SpotifyId::from_id_or_uri(s) { s: impl IntoIterator<Item = &'a String>,
return Ok(id); ) -> Result<Vec<T>, Error> {
}
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: AsRef<str>>(s: impl IntoIterator<Item = S>) -> Result<Vec<SpotifyId>, Error> {
s.into_iter() s.into_iter()
.map(|s| parse_url_or_id(s.as_ref())) .map(|s| T::from_id_uri_or_url(s))
.collect::<Result<Vec<SpotifyId>, _>>() .collect::<Result<Vec<T>, _>>()
.map_err(|_| Error::InvalidInput("ids")) .map_err(|_| Error::InvalidInput("ids"))
} }

View file

@ -1,6 +1,6 @@
use std::{collections::HashMap, fmt::Debug}; use std::{collections::HashMap, fmt::Debug};
use spotifyio::{pb::AudioFileFormat, FileId}; use spotifyio::{model::FileId, pb::AudioFileFormat};
use spotifyio_protocol::metadata::AudioFile as AudioFileMessage; use spotifyio_protocol::metadata::AudioFile as AudioFileMessage;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]

View file

@ -1,7 +1,8 @@
use serde::Serialize; use serde::Serialize;
use spotifyio::{ use spotifyio::{
model::{AlbumId, ArtistId, FileId, IdConstruct, PlayableId, TrackId},
pb::{ArtistRole as PbArtistRole, ImageSize}, pb::{ArtistRole as PbArtistRole, ImageSize},
Error, FileId, SpotifyId, Error,
}; };
use spotifyio_protocol::metadata::{ use spotifyio_protocol::metadata::{
album::Type as AlbumType, Artist as ArtistMessage, ArtistWithRole as ArtistWithRoleMessage, album::Type as AlbumType, Artist as ArtistMessage, ArtistWithRole as ArtistWithRoleMessage,
@ -25,8 +26,8 @@ pub struct CoverImage {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AudioItem { pub struct AudioItem {
pub track_id: SpotifyId, pub track_id: PlayableId<'static>,
pub actual_id: Option<SpotifyId>, pub actual_id: Option<PlayableId<'static>>,
pub files: AudioFiles, pub files: AudioFiles,
pub name: String, pub name: String,
pub covers: Vec<CoverImage>, pub covers: Vec<CoverImage>,
@ -42,7 +43,7 @@ pub struct AudioItem {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TrackUniqueFields { pub struct TrackUniqueFields {
pub artists: Vec<ArtistWithRole>, pub artists: Vec<ArtistWithRole>,
pub album_id: SpotifyId, pub album_id: AlbumId<'static>,
pub album_name: String, pub album_name: String,
pub album_artists: Vec<ArtistItem>, pub album_artists: Vec<ArtistItem>,
pub album_type: AlbumType, pub album_type: AlbumType,
@ -65,7 +66,7 @@ pub enum UniqueFields {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AlternativeTrack { pub struct AlternativeTrack {
pub track_id: SpotifyId, pub track_id: TrackId<'static>,
pub availability: AudioItemAvailability, pub availability: AudioItemAvailability,
pub files: AudioFiles, pub files: AudioFiles,
} }
@ -87,9 +88,7 @@ impl TryFrom<ArtistWithRoleMessage> for ArtistWithRole {
type Error = Error; type Error = Error;
fn try_from(value: ArtistWithRoleMessage) -> Result<Self, Error> { fn try_from(value: ArtistWithRoleMessage) -> Result<Self, Error> {
Ok(Self { Ok(Self {
id: SpotifyId::from_raw(value.artist_gid())? id: ArtistId::from_raw(value.artist_gid())?.into_id(),
.to_base62()
.into_owned(),
role: value.role().into(), role: value.role().into(),
name: value.artist_name.unwrap_or_default(), name: value.artist_name.unwrap_or_default(),
}) })
@ -101,7 +100,7 @@ impl TryFrom<ArtistMessage> for ArtistItem {
fn try_from(value: ArtistMessage) -> Result<Self, Self::Error> { fn try_from(value: ArtistMessage) -> Result<Self, Self::Error> {
Ok(Self { 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(), name: value.name.unwrap_or_default(),
}) })
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "spotifyio-model" name = "spotifyio-model"
version = "0.1.0" version = "0.2.0"
authors = [ authors = [
"Ramsay Leung <ramsayleung@gmail.com>", "Ramsay Leung <ramsayleung@gmail.com>",
"Mario Ortiz Manero <marioortizmanero@gmail.com>", "Mario Ortiz Manero <marioortizmanero@gmail.com>",
@ -20,3 +20,5 @@ strum = { version = "0.26.1", features = ["derive"] }
thiserror = "2" thiserror = "2"
time = { version = "0.3.21", features = ["serde-well-known"] } time = { version = "0.3.21", features = ["serde-well-known"] }
data-encoding = "2.5" data-encoding = "2.5"
urlencoding = "2.1.0"
url = "2"

View file

@ -6,8 +6,8 @@ use time::OffsetDateTime;
use std::collections::HashMap; use std::collections::HashMap;
use crate::{ use crate::{
AlbumType, Copyright, DatePrecision, Image, Page, RestrictionReason, SimplifiedArtist, AlbumId, AlbumType, Copyright, DatePrecision, Image, Page, RestrictionReason, SimplifiedArtist,
SimplifiedTrack, SpotifyId, SimplifiedTrack,
}; };
/// Simplified Album Object /// Simplified Album Object
@ -20,8 +20,7 @@ pub struct SimplifiedAlbum {
#[serde(skip_serializing_if = "Vec::is_empty", default)] #[serde(skip_serializing_if = "Vec::is_empty", default)]
pub available_markets: Vec<String>, pub available_markets: Vec<String>,
pub href: Option<String>, pub href: Option<String>,
#[serde(with = "crate::spotify_id::ser::album::option")] pub id: Option<AlbumId<'static>>,
pub id: Option<SpotifyId>,
pub images: Vec<Image>, pub images: Vec<Image>,
pub name: String, pub name: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -42,8 +41,7 @@ pub struct FullAlbum {
pub external_ids: HashMap<String, String>, pub external_ids: HashMap<String, String>,
pub genres: Vec<String>, pub genres: Vec<String>,
pub href: String, pub href: String,
#[serde(with = "crate::spotify_id::ser::album")] pub id: AlbumId<'static>,
pub id: SpotifyId,
pub images: Vec<Image>, pub images: Vec<Image>,
pub name: String, pub name: String,
pub popularity: u32, pub popularity: u32,

View file

@ -2,14 +2,13 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{CursorBasedPage, Followers, Image, SpotifyId}; use crate::{ArtistId, CursorBasedPage, Followers, Image};
/// Simplified Artist Object /// Simplified Artist Object
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SimplifiedArtist { pub struct SimplifiedArtist {
pub href: Option<String>, pub href: Option<String>,
#[serde(with = "crate::spotify_id::ser::artist::option")] pub id: Option<ArtistId<'static>>,
pub id: Option<SpotifyId>,
pub name: String, pub name: String,
} }
@ -19,8 +18,7 @@ pub struct FullArtist {
pub followers: Followers, pub followers: Followers,
pub genres: Vec<String>, pub genres: Vec<String>,
pub href: String, pub href: String,
#[serde(with = "crate::spotify_id::ser::artist")] pub id: ArtistId<'static>,
pub id: SpotifyId,
pub images: Vec<Image>, pub images: Vec<Image>,
pub name: String, pub name: String,
pub popularity: u32, pub popularity: u32,

View file

@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{custom_serde::modality, Modality, SpotifyId}; use crate::{custom_serde::modality, Modality, TrackId};
/// Audio Feature Object /// Audio Feature Object
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@ -12,8 +12,7 @@ pub struct AudioFeatures {
pub danceability: f32, pub danceability: f32,
pub duration_ms: u32, pub duration_ms: u32,
pub energy: f32, pub energy: f32,
#[serde(with = "crate::spotify_id::ser::track")] pub id: TrackId<'static>,
pub id: SpotifyId,
pub instrumentalness: f32, pub instrumentalness: f32,
pub key: i32, pub key: i32,
pub liveness: f32, pub liveness: f32,

View file

@ -5,7 +5,7 @@ use time::OffsetDateTime;
use std::collections::HashMap; use std::collections::HashMap;
use crate::{CurrentlyPlayingType, Device, DisallowKey, PlayableItem, RepeatState, Type}; use crate::{CurrentlyPlayingType, Device, DisallowKey, PlayableItem, RepeatState, SpotifyType};
/// Context object /// Context object
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@ -14,7 +14,7 @@ pub struct Context {
pub uri: String, pub uri: String,
pub href: String, pub href: String,
#[serde(rename = "type")] #[serde(rename = "type")]
pub _type: Type, pub _type: SpotifyType,
} }
/// Currently playing object /// Currently playing object

View file

@ -30,7 +30,7 @@ pub enum AlbumType {
)] )]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")] #[strum(serialize_all = "snake_case")]
pub enum Type { pub enum SpotifyType {
Artist, Artist,
Album, Album,
Track, Track,
@ -40,8 +40,10 @@ pub enum Type {
Episode, Episode,
Collection, Collection,
Collectionyourepisodes, // rename to collectionyourepisodes Collectionyourepisodes, // rename to collectionyourepisodes
Local,
Concert, Concert,
Prerelease, Prerelease,
Songwriter,
} }
/// Additional typs: `track`, `episode` /// Additional typs: `track`, `episode`

View file

@ -2,7 +2,7 @@ use std::fmt;
use data_encoding::HEXLOWER_PERMISSIVE; use data_encoding::HEXLOWER_PERMISSIVE;
use crate::{spotify_id::to_base16, IdError}; use crate::IdError;
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FileId(pub [u8; 20]); pub struct FileId(pub [u8; 20]);
@ -25,21 +25,20 @@ impl FileId {
Ok(FileId(dst)) Ok(FileId(dst))
} }
#[allow(clippy::wrong_self_convention)] pub fn base16(&self) -> String {
pub fn to_base16(&self) -> Result<String, IdError> { HEXLOWER_PERMISSIVE.encode(&self.0)
to_base16(&self.0, &mut [0u8; 40])
} }
} }
impl fmt::Debug for FileId { impl fmt::Debug for FileId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 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 { impl fmt::Display for FileId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_base16().unwrap_or_default()) f.write_str(&self.base16())
} }
} }

1154
crates/model/src/idtypes.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ mod device;
mod enums; mod enums;
mod error; mod error;
mod file_id; mod file_id;
pub mod idtypes;
mod image; mod image;
mod offset; mod offset;
mod page; mod page;
@ -19,14 +20,13 @@ mod playlist;
mod recommend; mod recommend;
mod search; mod search;
mod show; mod show;
mod spotify_id;
mod track; mod track;
mod user; mod user;
pub use { pub use {
album::*, artist::*, audio::*, category::*, context::*, device::*, enums::*, error::*, album::*, artist::*, audio::*, category::*, context::*, device::*, enums::*, error::*,
file_id::*, image::*, offset::*, page::*, playing::*, playlist::*, recommend::*, search::*, file_id::*, idtypes::*, image::*, offset::*, page::*, playing::*, playlist::*, recommend::*,
show::*, spotify_id::*, track::*, user::*, search::*, show::*, track::*, user::*,
}; };
use serde::{Deserialize, Serialize}; 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 /// 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`. /// which case this function will return `None`.
#[must_use] #[must_use]
pub fn id(&self) -> Option<&SpotifyId> { pub fn id(&self) -> Option<PlayableId<'_>> {
match self { match self {
PlayableItem::Track(t) => t.id.as_ref(), PlayableItem::Track(t) => t.id.as_ref().map(|t| PlayableId::Track(t.as_ref())),
PlayableItem::Episode(e) => Some(&e.id), PlayableItem::Episode(e) => Some(PlayableId::Episode(e.id.as_ref())),
} }
} }
} }

View file

@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::{Followers, Image, Page, PlayableItem, PublicUser, SpotifyId}; use crate::{Followers, Image, Page, PlayableItem, PlaylistId, PublicUser};
/// Playlist result object /// Playlist result object
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
@ -31,8 +31,7 @@ where
pub struct SimplifiedPlaylist { pub struct SimplifiedPlaylist {
pub collaborative: bool, pub collaborative: bool,
pub href: String, pub href: String,
#[serde(with = "crate::spotify_id::ser::playlist")] pub id: PlaylistId<'static>,
pub id: SpotifyId,
#[serde(deserialize_with = "deserialize_null_default")] #[serde(deserialize_with = "deserialize_null_default")]
pub images: Vec<Image>, pub images: Vec<Image>,
pub name: String, pub name: String,
@ -49,8 +48,7 @@ pub struct FullPlaylist {
pub description: Option<String>, pub description: Option<String>,
pub followers: Followers, pub followers: Followers,
pub href: String, pub href: String,
#[serde(with = "crate::spotify_id::ser::playlist")] pub id: PlaylistId<'static>,
pub id: SpotifyId,
pub images: Vec<Image>, pub images: Vec<Image>,
pub name: String, pub name: String,
pub owner: PublicUser, pub owner: PublicUser,

View file

@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{CopyrightType, DatePrecision, Image, Page, SpotifyId}; use crate::{CopyrightType, DatePrecision, EpisodeId, Image, Page, ShowId};
/// Copyright object /// Copyright object
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@ -18,8 +18,7 @@ pub struct SimplifiedShow {
pub description: String, pub description: String,
pub explicit: bool, pub explicit: bool,
pub href: String, pub href: String,
#[serde(with = "crate::spotify_id::ser::show")] pub id: ShowId<'static>,
pub id: SpotifyId,
pub images: Vec<Image>, pub images: Vec<Image>,
pub is_externally_hosted: Option<bool>, pub is_externally_hosted: Option<bool>,
pub languages: Vec<String>, pub languages: Vec<String>,
@ -50,8 +49,7 @@ pub struct FullShow {
pub explicit: bool, pub explicit: bool,
pub episodes: Page<SimplifiedEpisode>, pub episodes: Page<SimplifiedEpisode>,
pub href: String, pub href: String,
#[serde(with = "crate::spotify_id::ser::show")] pub id: ShowId<'static>,
pub id: SpotifyId,
pub images: Vec<Image>, pub images: Vec<Image>,
pub is_externally_hosted: Option<bool>, pub is_externally_hosted: Option<bool>,
pub languages: Vec<String>, pub languages: Vec<String>,
@ -68,8 +66,7 @@ pub struct SimplifiedEpisode {
pub duration_ms: u32, pub duration_ms: u32,
pub explicit: bool, pub explicit: bool,
pub href: String, pub href: String,
#[serde(with = "crate::spotify_id::ser::episode")] pub id: EpisodeId<'static>,
pub id: SpotifyId,
pub images: Vec<Image>, pub images: Vec<Image>,
pub is_externally_hosted: bool, pub is_externally_hosted: bool,
pub is_playable: bool, pub is_playable: bool,
@ -92,8 +89,7 @@ pub struct FullEpisode {
pub duration_ms: u32, pub duration_ms: u32,
pub explicit: bool, pub explicit: bool,
pub href: String, pub href: String,
#[serde(with = "crate::spotify_id::ser::episode")] pub id: EpisodeId<'static>,
pub id: SpotifyId,
pub images: Vec<Image>, pub images: Vec<Image>,
pub is_externally_hosted: bool, pub is_externally_hosted: bool,
pub is_playable: bool, pub is_playable: bool,

View file

@ -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)] #[derive(Clone, PartialEq, Eq, Hash)]
pub struct SpotifyId { pub struct SpotifyId {
id: SpotifyIdInner, id: SpotifyIdInner,
@ -153,7 +147,7 @@ impl SpotifyId {
/// ///
/// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
pub fn from_base62(src: &str) -> Result<Self, IdError> { pub fn from_base62(src: &str) -> Result<Self, IdError> {
if src.len() != 22 { if src.len() != Self::SIZE_BASE62 {
return Err(IdError::InvalidId); return Err(IdError::InvalidId);
} }
let mut dst: u128 = 0; 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<Self, IdError> {
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. /// Creates a `u128` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order.
/// ///
/// The resulting `SpotifyId` will default to a `SpotifyItemType::Unknown`. /// 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 (tpe, id) = rest.split_once(sep).ok_or(IdError::InvalidFormat)?;
let item_type = SpotifyItemType::from(tpe); let item_type = SpotifyItemType::from(tpe);
if item_type.uses_textual_id() { if id.is_empty() {
if id.is_empty() { return Err(IdError::InvalidId);
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()), id: SpotifyIdInner::Textual(id.to_owned()),
item_type, item_type,
}) }),
} else { _ => Ok(Self {
if id.len() != Self::SIZE_BASE62 {
return Err(IdError::InvalidId);
}
Ok(Self {
item_type, item_type,
..Self::from_base62(id)? ..Self::from_base62(id)?
}) }),
} }
} }
pub fn from_id_or_uri(id_or_uri: &str) -> Result<Self, IdError> { pub fn from_id_or_uri(id_or_uri: &str) -> Result<Self, IdError> {
match Self::from_uri(id_or_uri) { match Self::from_uri(id_or_uri) {
Ok(id) => Ok(id), 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), Err(error) => Err(error),
} }
} }
@ -250,8 +277,11 @@ impl SpotifyId {
/// character long `String`. /// character long `String`.
/// ///
/// [canonically]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids /// [canonically]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
#[allow(clippy::wrong_self_convention)]
pub fn to_base62(&self) -> Cow<'_, str> { pub fn to_base62(&self) -> Cow<'_, str> {
self._to_base62(false)
}
fn _to_base62(&self, uri: bool) -> Cow<'_, str> {
match &self.id { match &self.id {
SpotifyIdInner::Numeric(n) => { SpotifyIdInner::Numeric(n) => {
let mut dst = [0u8; 22]; let mut dst = [0u8; 22];
@ -292,7 +322,13 @@ impl SpotifyId {
String::from_utf8(dst.to_vec()).unwrap().into() 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("spotify:");
dst.push_str(item_type); dst.push_str(item_type);
dst.push(':'); dst.push(':');
dst.push_str(&self.to_base62()); dst.push_str(&self._to_base62(true));
dst dst
} }
@ -335,10 +371,17 @@ impl SpotifyId {
dst.push_str("spotify:"); dst.push_str("spotify:");
dst.push_str(item_type); dst.push_str(item_type);
dst.push(':'); dst.push(':');
dst.push_str(&self.to_base62()); dst.push_str(&self._to_base62(true));
Ok(dst) 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 { pub fn into_type(self, typ: SpotifyItemType) -> Self {
Self { Self {
id: self.id, id: self.id,
@ -383,7 +426,7 @@ impl fmt::Debug for SpotifyId {
impl fmt::Display for SpotifyId { impl fmt::Display for SpotifyId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_uri()) f.write_str(&self.to_base62())
} }
} }
@ -403,7 +446,7 @@ impl<'de> Deserialize<'de> for SpotifyId {
{ {
struct SpotifyIdVisitor; struct SpotifyIdVisitor;
impl<'de> Visitor<'de> for SpotifyIdVisitor { impl Visitor<'_> for SpotifyIdVisitor {
type Value = SpotifyId; type Value = SpotifyId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@ -429,7 +472,7 @@ pub mod ser {
struct SpecificIdVisitor; struct SpecificIdVisitor;
impl<'de> Visitor<'de> for SpecificIdVisitor { impl Visitor<'_> for SpecificIdVisitor {
type Value = SpotifyId; type Value = SpotifyId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@ -440,13 +483,13 @@ pub mod ser {
where where
E: serde::de::Error, 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}`")))
} }
} }
struct OptionalSpecificIdVisitor; struct OptionalSpecificIdVisitor;
impl<'de> Visitor<'de> for OptionalSpecificIdVisitor { impl Visitor<'_> for OptionalSpecificIdVisitor {
type Value = Option<SpotifyId>; type Value = Option<SpotifyId>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@ -471,7 +514,7 @@ pub mod ser {
where where
E: serde::de::Error, E: serde::de::Error,
{ {
SpotifyId::from_base62(v) SpotifyId::from_textual(v)
.map(Some) .map(Some)
.map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`"))) .map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`")))
} }

View file

@ -5,7 +5,7 @@ use time::OffsetDateTime;
use std::collections::HashMap; use std::collections::HashMap;
use crate::{Restriction, SimplifiedAlbum, SimplifiedArtist, SpotifyId, Type}; use crate::{PlayableId, Restriction, SimplifiedAlbum, SimplifiedArtist, SpotifyType, TrackId};
/// Full track object /// Full track object
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@ -20,8 +20,7 @@ pub struct FullTrack {
pub external_ids: HashMap<String, String>, pub external_ids: HashMap<String, String>,
pub href: Option<String>, pub href: Option<String>,
/// Note that a track may not have an ID/URI if it's local /// 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<TrackId<'static>>,
pub id: Option<SpotifyId>,
pub is_local: bool, pub is_local: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub is_playable: Option<bool>, pub is_playable: Option<bool>,
@ -40,9 +39,8 @@ pub struct FullTrack {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrackLink { pub struct TrackLink {
pub href: String, pub href: String,
#[serde(with = "crate::spotify_id::ser::track::option")] pub id: Option<TrackId<'static>>,
pub id: Option<SpotifyId>, pub r#type: SpotifyType,
pub r#type: Type,
pub uri: String, pub uri: String,
} }
@ -67,8 +65,7 @@ pub struct SimplifiedTrack {
pub explicit: bool, pub explicit: bool,
#[serde(default)] #[serde(default)]
pub href: Option<String>, pub href: Option<String>,
#[serde(with = "crate::spotify_id::ser::track::option")] pub id: Option<TrackId<'static>>,
pub id: Option<SpotifyId>,
pub is_local: bool, pub is_local: bool,
pub is_playable: Option<bool>, pub is_playable: Option<bool>,
pub linked_from: Option<TrackLink>, pub linked_from: Option<TrackLink>,
@ -92,6 +89,6 @@ pub struct SavedTrack {
/// `PlayableId<'a>` instead of `PlayableId<'static>` to avoid the unnecessary /// `PlayableId<'a>` instead of `PlayableId<'static>` to avoid the unnecessary
/// allocation. Same goes for the positions slice instead of vector. /// allocation. Same goes for the positions slice instead of vector.
pub struct ItemPositions<'a> { pub struct ItemPositions<'a> {
pub id: SpotifyId, pub id: PlayableId<'static>,
pub positions: &'a [u32], pub positions: &'a [u32],
} }

View file

@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{Country, Followers, Image, SpotifyId, SubscriptionLevel}; use crate::{Country, Followers, Image, SubscriptionLevel, UserId};
/// Public user object /// Public user object
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@ -10,8 +10,7 @@ pub struct PublicUser {
pub display_name: Option<String>, pub display_name: Option<String>,
pub followers: Option<Followers>, pub followers: Option<Followers>,
pub href: String, pub href: String,
#[serde(with = "crate::spotify_id::ser::user")] pub id: UserId<'static>,
pub id: SpotifyId,
#[serde(default = "Vec::new")] #[serde(default = "Vec::new")]
pub images: Vec<Image>, pub images: Vec<Image>,
} }
@ -25,8 +24,7 @@ pub struct PrivateUser {
pub explicit_content: Option<ExplicitContent>, pub explicit_content: Option<ExplicitContent>,
pub followers: Option<Followers>, pub followers: Option<Followers>,
pub href: String, pub href: String,
#[serde(with = "crate::spotify_id::ser::user")] pub id: UserId<'static>,
pub id: SpotifyId,
pub images: Option<Vec<Image>>, pub images: Option<Vec<Image>>,
pub product: Option<SubscriptionLevel>, pub product: Option<SubscriptionLevel>,
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "spotifyio" name = "spotifyio"
version = "0.0.1" version = "0.0.2"
description = "Internal Spotify API Client" description = "Internal Spotify API Client"
edition.workspace = true edition.workspace = true
authors.workspace = true authors.workspace = true
@ -69,7 +69,7 @@ pin-project-lite = "0.2"
quick-xml = "0.37" quick-xml = "0.37"
urlencoding = "2.1.0" urlencoding = "2.1.0"
parking_lot = "0.12.0" parking_lot = "0.12.0"
governor = { version = "0.7", default-features = false, features = [ governor = { version = "0.8", default-features = false, features = [
"std", "std",
"quanta", "quanta",
"jitter", "jitter",
@ -83,6 +83,7 @@ spotifyio-model.workspace = true
[dev-dependencies] [dev-dependencies]
tracing-test = "0.2.5" tracing-test = "0.2.5"
hex_lit = "0.1" hex_lit = "0.1"
protobuf-json-mapping = "3"
[package.metadata.docs.rs] [package.metadata.docs.rs]
# To build locally: # To build locally:

View file

@ -2,11 +2,12 @@ use std::{collections::HashMap, io::Write, time::Duration};
use byteorder::{BigEndian, ByteOrder, WriteBytesExt}; use byteorder::{BigEndian, ByteOrder, WriteBytesExt};
use bytes::Bytes; use bytes::Bytes;
use spotifyio_model::{IdBase62, PlayableId};
use thiserror::Error; use thiserror::Error;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use tracing::{error, trace}; 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)] #[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)]
pub struct AudioKey(pub [u8; 16]); 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()))] #[tracing::instrument("audio_key", level = "error", skip(self), fields(usr = self.session().user_id()))]
pub async fn request(&self, track: &SpotifyId, file: &FileId) -> Result<AudioKey, Error> { pub async fn request(&self, track: PlayableId<'_>, file: &FileId) -> Result<AudioKey, Error> {
// Make sure a connection is established // Make sure a connection is established
self.session().connect_stored_creds().await?; 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<u8> = Vec::new(); let mut data: Vec<u8> = Vec::new();
data.write_all(&file.0)?; data.write_all(&file.0)?;
data.write_all(&track.to_raw()?)?; data.write_all(&track.raw())?;
data.write_u32::<BigEndian>(seq)?; data.write_u32::<BigEndian>(seq)?;
data.write_u16::<BigEndian>(0x0000)?; data.write_u16::<BigEndian>(0x0000)?;

View file

@ -5,7 +5,7 @@ use time::{Duration, OffsetDateTime};
use tracing::{trace, warn}; use tracing::{trace, warn};
use url::Url; 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::storage_resolve_response::Result as StorageResolveResponse_Result;
use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage; use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage;
@ -77,7 +77,7 @@ impl CdnUrl {
Ok(cdn_url) 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() { if self.urls.is_empty() {
return Err(CdnUrlError::Unresolved.into()); return Err(CdnUrlError::Unresolved.into());
} }
@ -89,7 +89,7 @@ impl CdnUrl {
}); });
if let Some(url) = url { if let Some(url) = url {
Ok(&url.0) Ok(url)
} else { } else {
Err(CdnUrlError::Expired.into()) Err(CdnUrlError::Expired.into())
} }

View file

@ -226,11 +226,11 @@ where
Ok(message) Ok(message)
} }
async fn read_into_accumulator<'a, 'b, T: AsyncRead + Unpin>( async fn read_into_accumulator<'a, T: AsyncRead + Unpin>(
connection: &'a mut T, connection: &mut T,
size: usize, size: usize,
acc: &'b mut Vec<u8>, acc: &'a mut Vec<u8>,
) -> io::Result<&'b mut [u8]> { ) -> io::Result<&'a mut [u8]> {
let offset = acc.len(); let offset = acc.len();
acc.resize(offset + size, 0); acc.resize(offset + size, 0);

View file

@ -70,8 +70,8 @@ pub enum ErrorKind {
#[error("Service unavailable")] #[error("Service unavailable")]
Unavailable = 14, Unavailable = 14,
#[error("Unrecoverable data loss or corruption")] #[error("Entity has not been modified since the last request")]
DataLoss = 15, NotModified = 15,
#[error("Operation must not be used")] #[error("Operation must not be used")]
DoNotUse = -1, DoNotUse = -1,
@ -127,12 +127,12 @@ impl Error {
} }
} }
pub fn data_loss<E>(error: E) -> Error pub fn not_modified<E>(error: E) -> Error
where where
E: Into<Box<dyn error::Error + Send + Sync>>, E: Into<Box<dyn error::Error + Send + Sync>>,
{ {
Self { Self {
kind: ErrorKind::DataLoss, kind: ErrorKind::NotModified,
error: error.into(), error: error.into(),
} }
} }
@ -297,6 +297,7 @@ impl From<reqwest::Error> for Error {
StatusCode::UNAUTHORIZED => Self::unauthenticated(err), StatusCode::UNAUTHORIZED => Self::unauthenticated(err),
StatusCode::FORBIDDEN => Self::permission_denied(err), StatusCode::FORBIDDEN => Self::permission_denied(err),
StatusCode::TOO_MANY_REQUESTS => Self::resource_exhausted(err), StatusCode::TOO_MANY_REQUESTS => Self::resource_exhausted(err),
StatusCode::GATEWAY_TIMEOUT => Self::deadline_exceeded(err),
_ => Self::unknown(err), _ => Self::unknown(err),
} }
} else if err.is_body() || err.is_request() { } else if err.is_body() || err.is_request() {
@ -451,3 +452,22 @@ impl From<IdError> for Error {
Self::invalid_argument(value) Self::invalid_argument(value)
} }
} }
pub trait NotModifiedRes<T> {
fn into_option(self) -> Result<Option<T>, Error>;
}
impl<T> NotModifiedRes<T> for Result<T, Error> {
fn into_option(self) -> Result<Option<T>, Error> {
match self {
Ok(res) => Ok(Some(res)),
Err(e) => {
if matches!(e.kind, ErrorKind::NotModified) {
Ok(None)
} else {
Err(e)
}
}
}
}
}

View file

@ -6,7 +6,9 @@ use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DefaultOnError, DisplayFromStr}; use serde_with::{serde_as, DefaultOnError, DisplayFromStr};
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::SpotifyId; use spotifyio_model::{
AlbumId, ArtistId, ConcertId, PlaylistId, PrereleaseId, SongwriterId, TrackId, UserId,
};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct LyricsWrap { pub(crate) struct LyricsWrap {
@ -29,7 +31,7 @@ pub struct GqlWrap<T> {
#[serde(tag = "__typename")] #[serde(tag = "__typename")]
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]
pub enum PlaylistOption { pub enum PlaylistOption {
Playlist(PlaylistItem), Playlist(GqlPlaylistItem),
#[serde(alias = "GenericError")] #[serde(alias = "GenericError")]
NotFound, NotFound,
} }
@ -100,7 +102,7 @@ pub(crate) struct ArtistGqlWrap {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[non_exhaustive] #[non_exhaustive]
pub struct ArtistGql { pub struct ArtistGql {
pub uri: SpotifyId, pub uri: ArtistId<'static>,
pub profile: ArtistProfile, pub profile: ArtistProfile,
pub related_content: Option<RelatedContent>, pub related_content: Option<RelatedContent>,
pub stats: Option<ArtistStats>, pub stats: Option<ArtistStats>,
@ -120,7 +122,10 @@ pub struct ArtistProfile {
pub verified: bool, pub verified: bool,
#[serde(default)] #[serde(default)]
pub biography: Biography, pub biography: Biography,
pub playlists_v2: Option<GqlPagination<GqlWrap<PlaylistOption>>>, #[serde(default)]
pub external_links: GqlPagination<ExternalLink>,
#[serde(default)]
pub playlists_v2: GqlPagination<GqlWrap<PlaylistOption>>,
} }
#[derive(Default, Debug, Clone, Serialize, Deserialize)] #[derive(Default, Debug, Clone, Serialize, Deserialize)]
@ -129,6 +134,13 @@ pub struct Biography {
pub text: Option<String>, pub text: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ExternalLink {
pub name: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[non_exhaustive] #[non_exhaustive]
@ -177,7 +189,7 @@ pub struct Events {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[non_exhaustive] #[non_exhaustive]
pub struct Concert { pub struct Concert {
pub uri: SpotifyId, pub uri: ConcertId<'static>,
pub title: String, pub title: String,
pub date: DateWrap, pub date: DateWrap,
#[serde(default)] #[serde(default)]
@ -258,8 +270,8 @@ pub struct Name {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[non_exhaustive] #[non_exhaustive]
pub struct PlaylistItem { pub struct GqlPlaylistItem {
pub uri: SpotifyId, pub uri: PlaylistId<'static>,
pub name: String, pub name: String,
pub images: GqlPagination<Image>, pub images: GqlPagination<Image>,
pub owner_v2: GqlWrap<UserItem>, pub owner_v2: GqlWrap<UserItem>,
@ -269,9 +281,9 @@ pub struct PlaylistItem {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[non_exhaustive] #[non_exhaustive]
pub struct UserItem { pub struct UserItem {
pub uri: Option<SpotifyId>, pub uri: Option<UserId<'static>>,
#[serde(alias = "displayName")] #[serde(alias = "displayName")]
pub name: String, pub name: Option<String>,
pub avatar: Option<Image>, pub avatar: Option<Image>,
} }
@ -279,7 +291,7 @@ pub struct UserItem {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[non_exhaustive] #[non_exhaustive]
pub struct ArtistItem { pub struct ArtistItem {
pub uri: SpotifyId, pub uri: ArtistId<'static>,
pub profile: Name, pub profile: Name,
pub visuals: Option<Visuals>, pub visuals: Option<Visuals>,
} }
@ -306,7 +318,7 @@ pub(crate) struct PrereleaseLookup {
#[non_exhaustive] #[non_exhaustive]
pub struct PrereleaseItem { pub struct PrereleaseItem {
/// URI of the prerelease /// URI of the prerelease
pub uri: SpotifyId, pub uri: PrereleaseId<'static>,
pub pre_release_content: PrereleaseContent, pub pre_release_content: PrereleaseContent,
pub release_date: DateWrap, pub release_date: DateWrap,
} }
@ -316,9 +328,9 @@ pub struct PrereleaseItem {
#[non_exhaustive] #[non_exhaustive]
pub struct PrereleaseContent { pub struct PrereleaseContent {
/// URI of the to-be-released album /// URI of the to-be-released album
pub uri: Option<SpotifyId>, pub uri: Option<AlbumId<'static>>,
pub name: String, pub name: String,
pub cover_art: Image, pub cover_art: Option<Image>,
pub artists: Option<GqlPagination<GqlWrap<ArtistItem>>>, pub artists: Option<GqlPagination<GqlWrap<ArtistItem>>>,
pub tracks: Option<GqlPagination<PrereleaseTrackItem>>, pub tracks: Option<GqlPagination<PrereleaseTrackItem>>,
pub copyright: Option<String>, pub copyright: Option<String>,
@ -328,7 +340,7 @@ pub struct PrereleaseContent {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[non_exhaustive] #[non_exhaustive]
pub struct PrereleaseTrackItem { pub struct PrereleaseTrackItem {
pub uri: SpotifyId, pub uri: TrackId<'static>,
pub name: String, pub name: String,
pub duration: Option<DurationWrap>, pub duration: Option<DurationWrap>,
pub artists: GqlPagination<GqlWrap<ArtistItem>>, pub artists: GqlPagination<GqlWrap<ArtistItem>>,
@ -395,10 +407,10 @@ pub enum AlbumItemWrap {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[non_exhaustive] #[non_exhaustive]
pub struct AlbumItem { pub struct AlbumItem {
pub uri: SpotifyId, pub uri: AlbumId<'static>,
pub name: String, pub name: String,
pub date: Option<DateYear>, pub date: Option<DateYear>,
pub cover_art: Image, pub cover_art: Option<Image>,
pub artists: Option<GqlPagination<ArtistItem>>, pub artists: Option<GqlPagination<ArtistItem>>,
} }
@ -406,7 +418,7 @@ pub struct AlbumItem {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[non_exhaustive] #[non_exhaustive]
pub struct TrackItem { pub struct TrackItem {
pub uri: SpotifyId, pub uri: TrackId<'static>,
pub name: String, pub name: String,
pub duration: DurationWrap, pub duration: DurationWrap,
pub artists: GqlPagination<ArtistItem>, pub artists: GqlPagination<ArtistItem>,
@ -423,7 +435,7 @@ pub(crate) struct ConcertGqlWrap {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[non_exhaustive] #[non_exhaustive]
pub struct ConcertGql { pub struct ConcertGql {
pub uri: SpotifyId, pub uri: ConcertId<'static>,
pub title: String, pub title: String,
#[serde(default)] #[serde(default)]
pub artists: GqlPagination<GqlWrap<ArtistItem>>, pub artists: GqlPagination<GqlWrap<ArtistItem>>,
@ -469,6 +481,7 @@ pub struct ConcertOfferDates {
pub end_date_iso_string: OffsetDateTime, pub end_date_iso_string: OffsetDateTime,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchItemType { pub enum SearchItemType {
Artists, Artists,
Albums, Albums,
@ -480,7 +493,7 @@ pub enum SearchItemType {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive] #[non_exhaustive]
pub struct UserProfile { pub struct UserProfile {
pub uri: SpotifyId, pub uri: UserId<'static>,
pub name: Option<String>, pub name: Option<String>,
pub image_url: Option<String>, pub image_url: Option<String>,
pub followers_count: Option<u32>, pub followers_count: Option<u32>,
@ -490,18 +503,26 @@ pub struct UserProfile {
pub total_public_playlists_count: Option<u32>, pub total_public_playlists_count: Option<u32>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UserPlaylists {
#[serde(default)]
pub public_playlists: Vec<PublicPlaylistItem>,
pub total_public_playlists_count: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive] #[non_exhaustive]
pub struct PublicPlaylistItem { pub struct PublicPlaylistItem {
pub uri: SpotifyId, pub uri: PlaylistId<'static>,
pub name: String, pub name: String,
pub owner_name: String, pub owner_name: String,
pub owner_uri: String, pub owner_uri: UserId<'static>,
/// UID-based image id /// UID-based image id
/// ///
/// - `spotify:image:ab67706c0000da8474fffd106bb7f5be3ba4b758` /// - `spotify:image:ab67706c0000da8474fffd106bb7f5be3ba4b758`
/// - `spotify:mosaic:ab67616d00001e021c04efd2804b16cf689de7f0:ab67616d00001e0269f63a842ea91ca7c522593a:ab67616d00001e0270dbc9f47669d120ad874ec1:ab67616d00001e027d384516b23347e92a587ed1` /// - `spotify:mosaic:ab67616d00001e021c04efd2804b16cf689de7f0:ab67616d00001e0269f63a842ea91ca7c522593a:ab67616d00001e0270dbc9f47669d120ad874ec1:ab67616d00001e027d384516b23347e92a587ed1`
pub image_url: String, pub image_url: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -513,7 +534,7 @@ pub(crate) struct UserProfilesWrap {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive] #[non_exhaustive]
pub struct FollowerItem { pub struct FollowerItem {
pub uri: SpotifyId, pub uri: UserId<'static>,
pub name: Option<String>, pub name: Option<String>,
pub followers_count: Option<u32>, pub followers_count: Option<u32>,
pub image_url: Option<String>, pub image_url: Option<String>,
@ -526,6 +547,7 @@ pub struct Seektable {
pub encoder_delay_samples: u32, pub encoder_delay_samples: u32,
pub pssh: String, pub pssh: String,
pub timescale: u32, pub timescale: u32,
#[serde(alias = "init_range")]
pub index_range: (u32, u32), pub index_range: (u32, u32),
pub segments: Vec<(u32, u32)>, pub segments: Vec<(u32, u32)>,
pub offset: usize, pub offset: usize,
@ -535,7 +557,7 @@ pub struct Seektable {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TrackCredits { pub struct TrackCredits {
pub track_uri: SpotifyId, pub track_uri: TrackId<'static>,
pub track_title: String, pub track_title: String,
#[serde(default)] #[serde(default)]
pub role_credits: Vec<RoleCredits>, pub role_credits: Vec<RoleCredits>,
@ -555,11 +577,47 @@ pub struct RoleCredits {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CreditedArtist { pub struct CreditedArtist {
pub name: String, pub name: String,
#[serde(alias = "creatorUri")] pub uri: Option<ArtistId<'static>>,
pub uri: Option<SpotifyId>, pub creator_uri: Option<SongwriterId<'static>>,
pub external_url: Option<String>, pub external_url: Option<String>,
/// Image URL /// Image URL
pub image_uri: Option<String>, pub image_uri: Option<String>,
pub subroles: Vec<String>, pub subroles: Vec<String>,
pub weight: f32, 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<GqlPlaylistItem> {
match self {
PlaylistOption::Playlist(playlist_item) => Some(playlist_item),
PlaylistOption::NotFound => None,
}
}
}
impl ConcertOption {
pub fn into_option(self) -> Option<ConcertGql> {
match self {
ConcertOption::ConcertV2(concert) => Some(concert),
ConcertOption::NotFound => None,
}
}
}
impl From<GqlWrap<PlaylistOption>> for Option<GqlPlaylistItem> {
fn from(value: GqlWrap<PlaylistOption>) -> Self {
value.data.into_option()
}
}
impl From<GqlWrap<ConcertOption>> for Option<ConcertGql> {
fn from(value: GqlWrap<ConcertOption>) -> Self {
value.data.into_option()
}
}

View file

@ -1,6 +1,6 @@
#![doc = include_str!("../README.md")] #![doc = include_str!("../README.md")]
#![cfg_attr(docsrs, feature(doc_cfg))] #![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(clippy::todo, clippy::dbg_macro)] #![warn(clippy::todo)]
#[macro_use] #[macro_use]
mod component; mod component;
@ -18,7 +18,7 @@ mod pool;
mod session; mod session;
mod spclient; mod spclient;
pub mod model; pub mod gql_model;
pub mod util; pub mod util;
#[cfg(feature = "oauth")] #[cfg(feature = "oauth")]
@ -36,19 +36,17 @@ use spclient::SpClient;
pub use audio_key::{AudioKey, AudioKeyError}; pub use audio_key::{AudioKey, AudioKeyError};
pub use authentication::AuthCredentials; pub use authentication::AuthCredentials;
pub use cache::{ApplicationCache, SessionCache}; pub use cache::{ApplicationCache, SessionCache};
pub use cdn_url::{CdnUrl, CdnUrlError}; pub use cdn_url::{CdnUrl, CdnUrlError, MaybeExpiringUrl};
pub use error::{Error, ErrorKind}; pub use error::{Error, ErrorKind, NotModifiedRes};
pub use governor::{Jitter, Quota}; pub use governor::{Jitter, Quota};
pub use normalisation::NormalisationData; pub use normalisation::NormalisationData;
pub use pool::PoolError; pub use pool::PoolError;
pub use session::{Session, SessionConfig}; 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,
};
pub use spotifyio_protocol::authentication::AuthenticationType; pub use spotifyio_protocol::authentication::AuthenticationType;
pub use spotifyio_model as model;
/// Protobuf enums /// Protobuf enums
pub mod pb { pub mod pb {
pub use spotifyio_protocol::canvaz_meta::Type as CanvazType; pub use spotifyio_protocol::canvaz_meta::Type as CanvazType;
@ -69,6 +67,7 @@ pub mod pb {
ListAttributeKind, ListAttributeKind,
}; };
pub use spotifyio_protocol::playlist_permission::PermissionLevel; pub use spotifyio_protocol::playlist_permission::PermissionLevel;
pub use spotifyio_protocol::*;
} }
#[derive(Clone)] #[derive(Clone)]
@ -182,6 +181,10 @@ impl SpotifyIoPool {
Ok(self.inner.pool.get().await?.audio_key()) 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( pub async fn audio_key_timeout(
&self, &self,
timeout: Duration, timeout: Duration,
@ -189,6 +192,13 @@ impl SpotifyIoPool {
Ok(self.inner.pool.get_timeout(timeout).await?.audio_key()) 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) { pub fn shutdown(&self) {
for session in self.inner.pool.iter() { for session in self.inner.pool.iter() {
session.shutdown(); session.shutdown();
@ -196,6 +206,7 @@ impl SpotifyIoPool {
} }
#[cfg(test)] #[cfg(test)]
#[allow(unused)]
pub(crate) fn testing() -> Self { pub(crate) fn testing() -> Self {
use path_macro::path; use path_macro::path;

View file

@ -25,7 +25,7 @@ impl NormalisationData {
let newpos = file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?; let newpos = file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?;
if newpos != SPOTIFY_NORMALIZATION_HEADER_START_OFFSET { if newpos != SPOTIFY_NORMALIZATION_HEADER_START_OFFSET {
return Err(Error::failed_precondition(format!( 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 SPOTIFY_NORMALIZATION_HEADER_START_OFFSET, newpos
))); )));
} }

View file

@ -14,7 +14,7 @@ use oauth2::{
basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge,
RedirectUrl, Scope, TokenResponse, TokenUrl, RedirectUrl, Scope, TokenResponse, TokenUrl,
}; };
use spotifyio_model::PrivateUser; use spotifyio_model::{Id, PrivateUser};
use std::io; use std::io;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use std::{ use std::{
@ -280,7 +280,7 @@ pub async fn new_session(
access_token: &str, access_token: &str,
) -> Result<SessionCache, Error> { ) -> Result<SessionCache, Error> {
let profile = get_own_profile(access_token).await?; 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); sc.write_email(profile.email);
if let Some(country) = profile.country { if let Some(country) = profile.country {
let cstr: &'static str = country.into(); let cstr: &'static str = country.into();

View file

@ -34,9 +34,9 @@ use crate::{
cache::SessionCache, cache::SessionCache,
connection::{self, AuthenticationError, PacketType, Transport}, connection::{self, AuthenticationError, PacketType, Transport},
error::Error, error::Error,
model::FileId,
spclient::{RequestStrategy, SpClient}, spclient::{RequestStrategy, SpClient},
util::{self, SocketAddress}, util::{self, SocketAddress},
FileId,
}; };
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@ -387,18 +387,12 @@ impl Session {
} }
} }
pub fn image_url(&self, image_id: &FileId) -> Result<String, Error> { pub fn image_url(&self, image_id: &FileId) -> String {
Ok(format!( format!("https://i.scdn.co/image/{}", &image_id.base16())
"https://i.scdn.co/image/{}",
&image_id.to_base16()?
))
} }
pub fn audio_preview_url(&self, preview_id: &FileId) -> Result<String, Error> { pub fn audio_preview_url(&self, preview_id: &FileId) -> String {
Ok(format!( format!("https://p.scdn.co/mp3-preview/{}", &preview_id.base16())
"https://p.scdn.co/mp3-preview/{}",
&preview_id.to_base16()?
))
} }
pub fn shutdown(&self) { pub fn shutdown(&self) {
@ -738,11 +732,10 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{FileId, SpotifyId};
use super::*; use super::*;
use data_encoding::{BASE64, HEXLOWER}; use data_encoding::{BASE64, HEXLOWER};
use hex_lit::hex; use hex_lit::hex;
use spotifyio_model::{IdConstruct, PlayableId, TrackId};
use tracing_test::traced_test; use tracing_test::traced_test;
#[tokio::test] #[tokio::test]
@ -810,9 +803,9 @@ mod tests {
async fn connection_key() { async fn connection_key() {
let s = Session::new(SessionConfig::default(), SessionCache::testing()); 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 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")); assert_eq!(key.0, hex!("0ec8107d77338dcb4d31d5feb4e220fb"));
} }

File diff suppressed because it is too large Load diff

View file

@ -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<u32> {
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 { pub fn audio_format_available(format: AudioFileFormat, is_premium: bool) -> bool {
match format { match format {
AudioFileFormat::OGG_VORBIS_96 AudioFileFormat::OGG_VORBIS_96