Compare commits
10 commits
49b8d8a5f3
...
4a3b288a3c
Author | SHA1 | Date | |
---|---|---|---|
4a3b288a3c | |||
b774098619 | |||
c10079f581 | |||
2cd8107a11 | |||
7c4c2d2058 | |||
ad8817c8c6 | |||
b12543dbb8 | |||
5b50589b26 | |||
346a910c21 | |||
1599c85422 |
48 changed files with 2561 additions and 714 deletions
|
@ -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" }
|
||||||
|
|
|
@ -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"]}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
12
crates/downloader/.sqlx/query-07c4c0a0ba528964a13c8c9f8771dc37a92e9f9fbba314290e59c0cc7428158c.json
generated
Normal file
12
crates/downloader/.sqlx/query-07c4c0a0ba528964a13c8c9f8771dc37a92e9f9fbba314290e59c0cc7428158c.json
generated
Normal 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"
|
||||||
|
}
|
12
crates/downloader/.sqlx/query-17be8832946ce5585d93cb4e76d597624a633817d76ac34dd6378e7210b644e5.json
generated
Normal file
12
crates/downloader/.sqlx/query-17be8832946ce5585d93cb4e76d597624a633817d76ac34dd6378e7210b644e5.json
generated
Normal 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"
|
||||||
|
}
|
20
crates/downloader/.sqlx/query-419ae5a6ca51a68acf24c8fc46f846ce0be655af44a1fb7bf0501f31bc5ad715.json
generated
Normal file
20
crates/downloader/.sqlx/query-419ae5a6ca51a68acf24c8fc46f846ce0be655af44a1fb7bf0501f31bc5ad715.json
generated
Normal 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"
|
||||||
|
}
|
20
crates/downloader/.sqlx/query-4c6d9967a54a12626f864c6e388fcc93c1d98800fb4b771724fec8b38b6cbbae.json
generated
Normal file
20
crates/downloader/.sqlx/query-4c6d9967a54a12626f864c6e388fcc93c1d98800fb4b771724fec8b38b6cbbae.json
generated
Normal 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"
|
||||||
|
}
|
32
crates/downloader/.sqlx/query-77f3d314857a7d101506c00d4416565f194f575c8de0c6f57f7eb8ff21b65b8f.json
generated
Normal file
32
crates/downloader/.sqlx/query-77f3d314857a7d101506c00d4416565f194f575c8de0c6f57f7eb8ff21b65b8f.json
generated
Normal 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"
|
||||||
|
}
|
12
crates/downloader/.sqlx/query-84a7b6606d0e2f04c40c7bae39cff81e77b5871b5bf74bc9c6faf7658a946980.json
generated
Normal file
12
crates/downloader/.sqlx/query-84a7b6606d0e2f04c40c7bae39cff81e77b5871b5bf74bc9c6faf7658a946980.json
generated
Normal 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"
|
||||||
|
}
|
20
crates/downloader/.sqlx/query-8aa211d6b230db73f13394bb7a671af96993137b373c6647134853e93c57c923.json
generated
Normal file
20
crates/downloader/.sqlx/query-8aa211d6b230db73f13394bb7a671af96993137b373c6647134853e93c57c923.json
generated
Normal 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"
|
||||||
|
}
|
12
crates/downloader/.sqlx/query-a26fa646feb96469fcfaae8cd118eacddd65f0746c68243a27af1ec128dad3d1.json
generated
Normal file
12
crates/downloader/.sqlx/query-a26fa646feb96469fcfaae8cd118eacddd65f0746c68243a27af1ec128dad3d1.json
generated
Normal 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"
|
||||||
|
}
|
12
crates/downloader/.sqlx/query-a59e3b332fa9fb25c39f2d788fa837738be313b9cbc7e297ac15530efbaa7e3d.json
generated
Normal file
12
crates/downloader/.sqlx/query-a59e3b332fa9fb25c39f2d788fa837738be313b9cbc7e297ac15530efbaa7e3d.json
generated
Normal 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"
|
||||||
|
}
|
20
crates/downloader/.sqlx/query-a9e2d1d9656ad3f059dc1c17ab60703b5650aeb72f4253c9eddea533707aff73.json
generated
Normal file
20
crates/downloader/.sqlx/query-a9e2d1d9656ad3f059dc1c17ab60703b5650aeb72f4253c9eddea533707aff73.json
generated
Normal 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"
|
||||||
|
}
|
12
crates/downloader/.sqlx/query-d75195ed340e3961933bbe69946d03ecfa24f0237c40666088bacc7893a101ff.json
generated
Normal file
12
crates/downloader/.sqlx/query-d75195ed340e3961933bbe69946d03ecfa24f0237c40666088bacc7893a101ff.json
generated
Normal 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"
|
||||||
|
}
|
20
crates/downloader/.sqlx/query-de9c04eb8f8f961579e0b58244355be99eefc34938bab5ccdc0f01c0d1fa9e7d.json
generated
Normal file
20
crates/downloader/.sqlx/query-de9c04eb8f8f961579e0b58244355be99eefc34938bab5ccdc0f01c0d1fa9e7d.json
generated
Normal 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"
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
alter table artists add column etag TEXT;
|
|
@ -0,0 +1 @@
|
||||||
|
alter table tracks add column audio_features TEXT;
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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`
|
||||||
|
|
|
@ -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
1154
crates/model/src/idtypes.rs
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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}`")))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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],
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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)?;
|
||||||
|
|
||||||
|
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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
|
@ -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
|
||||||
|
|
Loading…
Add table
Reference in a new issue