Compare commits

..

No commits in common. "4a3b288a3c1b3bd5130872d64ae62996985dcfc1" and "49b8d8a5f3fa93d1019cce775ff95b27e07b48fd" have entirely different histories.

48 changed files with 714 additions and 2561 deletions

View file

@ -14,6 +14,6 @@ resolver = "2"
protobuf = "3.5"
# WS crates
spotifyio = { path = "crates/spotifyio", version = "0.0.2", registry = "thetadev" }
spotifyio = { path = "crates/spotifyio", version = "0.0.1", registry = "thetadev" }
spotifyio-protocol = { path = "crates/protocol", version = "0.1.0", registry = "thetadev" }
spotifyio-model = { path = "crates/model", version = "0.2.0", registry = "thetadev" }
spotifyio-model = { path = "crates/model", version = "0.1.0", registry = "thetadev" }

View file

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

View file

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

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "update artists set last_update=$2, etag=$3 where id=$1",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "07c4c0a0ba528964a13c8c9f8771dc37a92e9f9fbba314290e59c0cc7428158c"
}

View file

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

View file

@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "select count(*) as count from artists where active=1 and (last_update is null or last_update < $1)",
"describe": {
"columns": [
{
"name": "count",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "419ae5a6ca51a68acf24c8fc46f846ce0be655af44a1fb7bf0501f31bc5ad715"
}

View file

@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "select id from albums where id=$1",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "4c6d9967a54a12626f864c6e388fcc93c1d98800fb4b771724fec8b38b6cbbae"
}

View file

@ -1,32 +0,0 @@
{
"db_name": "SQLite",
"query": "select id, name, etag from artists where active=1 and (last_update is null or last_update < $1)",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "etag",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
true,
true
]
},
"hash": "77f3d314857a7d101506c00d4416565f194f575c8de0c6f57f7eb8ff21b65b8f"
}

View file

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

View file

@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "select genre from tracks where album_artist_id=? order by genre nulls last limit 1",
"describe": {
"columns": [
{
"name": "genre",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "8aa211d6b230db73f13394bb7a671af96993137b373c6647134853e93c57c923"
}

View file

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

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "insert into tracks (id, spotify_id, name, artist, artists, album, cover_url, audio_url, format, key,\n track_nr, disc_nr, downloaded, album_id, album_artist, album_artist_id, release_date, n_discs,\n album_type, genre, not_available, size, path)\n values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, true, $13, $14, $15, $16, $17, $18, $19, false, $20, $21)",
"describe": {
"columns": [],
"parameters": {
"Right": 21
},
"nullable": []
},
"hash": "a59e3b332fa9fb25c39f2d788fa837738be313b9cbc7e297ac15530efbaa7e3d"
}

View file

@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "select downloaded from tracks where id=$1",
"describe": {
"columns": [
{
"name": "downloaded",
"ordinal": 0,
"type_info": "Bool"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "a9e2d1d9656ad3f059dc1c17ab60703b5650aeb72f4253c9eddea533707aff73"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "update tracks set spotify_id=$2, name=$3, artist=$4, artists=$5,\n album=$6, cover_url=$7, audio_url=$8, format=$9, key=$10,\n track_nr=$11, disc_nr=$12, downloaded=true, album_id=$13,\n album_artist=$14, album_artist_id=$15, release_date=$16, n_discs=$17,\n album_type=$18, genre=$19, not_available=false, size=$20, path=$21\n where id=$1",
"describe": {
"columns": [],
"parameters": {
"Right": 21
},
"nullable": []
},
"hash": "d75195ed340e3961933bbe69946d03ecfa24f0237c40666088bacc7893a101ff"
}

View file

@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "select n_discs from tracks where album_id=? and n_discs is not null limit 1",
"describe": {
"columns": [
{
"name": "n_discs",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "de9c04eb8f8f961579e0b58244355be99eefc34938bab5ccdc0f01c0d1fa9e7d"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -91,6 +91,12 @@ impl std::fmt::Display for SpotifyItemType {
}
}
impl SpotifyItemType {
fn uses_textual_id(self) -> bool {
matches!(self, Self::User | Self::Local)
}
}
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct SpotifyId {
id: SpotifyIdInner,
@ -147,7 +153,7 @@ impl SpotifyId {
///
/// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
pub fn from_base62(src: &str) -> Result<Self, IdError> {
if src.len() != Self::SIZE_BASE62 {
if src.len() != 22 {
return Err(IdError::InvalidId);
}
let mut dst: u128 = 0;
@ -170,29 +176,6 @@ impl SpotifyId {
})
}
fn validate_textual_id(src: &str) -> Result<(), IdError> {
// forbidden chars: : /&@'"
if src.contains(['/', '&', '@', '\'', '"']) {
return Err(IdError::InvalidId);
}
Ok(())
}
/// Parse a textual Spotify ID (used for user IDs, which use the base62 format
/// for new accounts and the username for legacy accounts).
pub fn from_textual(src: &str) -> Result<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.
///
/// The resulting `SpotifyId` will default to a `SpotifyItemType::Unknown`.
@ -229,39 +212,29 @@ impl SpotifyId {
let (tpe, id) = rest.split_once(sep).ok_or(IdError::InvalidFormat)?;
let item_type = SpotifyItemType::from(tpe);
if id.is_empty() {
return Err(IdError::InvalidId);
}
match item_type {
SpotifyItemType::User => {
if let Ok(id) = Self::from_base62(id) {
return Ok(Self { item_type, ..id });
}
let decoded = urlencoding::decode(id)
.map_err(|_| IdError::InvalidId)?
.into_owned();
Self::validate_textual_id(&decoded)?;
Ok(Self {
id: SpotifyIdInner::Textual(decoded),
item_type,
})
if item_type.uses_textual_id() {
if id.is_empty() {
return Err(IdError::InvalidId);
}
SpotifyItemType::Local => Ok(Self {
Ok(Self {
id: SpotifyIdInner::Textual(id.to_owned()),
item_type,
}),
_ => Ok(Self {
})
} else {
if id.len() != Self::SIZE_BASE62 {
return Err(IdError::InvalidId);
}
Ok(Self {
item_type,
..Self::from_base62(id)?
}),
})
}
}
pub fn from_id_or_uri(id_or_uri: &str) -> Result<Self, IdError> {
match Self::from_uri(id_or_uri) {
Ok(id) => Ok(id),
Err(IdError::InvalidPrefix) => Self::from_textual(id_or_uri),
Err(IdError::InvalidPrefix) => Self::from_base62(id_or_uri),
Err(error) => Err(error),
}
}
@ -277,11 +250,8 @@ impl SpotifyId {
/// character long `String`.
///
/// [canonically]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
#[allow(clippy::wrong_self_convention)]
pub fn to_base62(&self) -> Cow<'_, str> {
self._to_base62(false)
}
fn _to_base62(&self, uri: bool) -> Cow<'_, str> {
match &self.id {
SpotifyIdInner::Numeric(n) => {
let mut dst = [0u8; 22];
@ -322,13 +292,7 @@ impl SpotifyId {
String::from_utf8(dst.to_vec()).unwrap().into()
}
SpotifyIdInner::Textual(id) => {
if uri {
urlencoding::encode(id)
} else {
id.into()
}
}
SpotifyIdInner::Textual(id) => id.into(),
}
}
@ -359,7 +323,7 @@ impl SpotifyId {
dst.push_str("spotify:");
dst.push_str(item_type);
dst.push(':');
dst.push_str(&self._to_base62(true));
dst.push_str(&self.to_base62());
dst
}
@ -371,17 +335,10 @@ impl SpotifyId {
dst.push_str("spotify:");
dst.push_str(item_type);
dst.push(':');
dst.push_str(&self._to_base62(true));
dst.push_str(&self.to_base62());
Ok(dst)
}
pub fn is_textual(&self) -> bool {
match self.id {
SpotifyIdInner::Numeric(_) => false,
SpotifyIdInner::Textual(_) => true,
}
}
pub fn into_type(self, typ: SpotifyItemType) -> Self {
Self {
id: self.id,
@ -426,7 +383,7 @@ impl fmt::Debug for SpotifyId {
impl fmt::Display for SpotifyId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_base62())
f.write_str(&self.to_uri())
}
}
@ -446,7 +403,7 @@ impl<'de> Deserialize<'de> for SpotifyId {
{
struct SpotifyIdVisitor;
impl Visitor<'_> for SpotifyIdVisitor {
impl<'de> Visitor<'de> for SpotifyIdVisitor {
type Value = SpotifyId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@ -472,7 +429,7 @@ pub mod ser {
struct SpecificIdVisitor;
impl Visitor<'_> for SpecificIdVisitor {
impl<'de> Visitor<'de> for SpecificIdVisitor {
type Value = SpotifyId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@ -483,13 +440,13 @@ pub mod ser {
where
E: serde::de::Error,
{
SpotifyId::from_textual(v).map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`")))
SpotifyId::from_base62(v).map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`")))
}
}
struct OptionalSpecificIdVisitor;
impl Visitor<'_> for OptionalSpecificIdVisitor {
impl<'de> Visitor<'de> for OptionalSpecificIdVisitor {
type Value = Option<SpotifyId>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@ -514,7 +471,7 @@ pub mod ser {
where
E: serde::de::Error,
{
SpotifyId::from_textual(v)
SpotifyId::from_base62(v)
.map(Some)
.map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`")))
}

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@ use time::{Duration, OffsetDateTime};
use tracing::{trace, warn};
use url::Url;
use crate::{model::FileId, Error, Session};
use super::{Error, FileId, Session};
use protocol::storage_resolve::storage_resolve_response::Result as StorageResolveResponse_Result;
use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage;
@ -77,7 +77,7 @@ impl CdnUrl {
Ok(cdn_url)
}
pub fn try_get_url(&self) -> Result<&MaybeExpiringUrl, Error> {
pub fn try_get_url(&self) -> Result<&str, Error> {
if self.urls.is_empty() {
return Err(CdnUrlError::Unresolved.into());
}
@ -89,7 +89,7 @@ impl CdnUrl {
});
if let Some(url) = url {
Ok(url)
Ok(&url.0)
} else {
Err(CdnUrlError::Expired.into())
}

View file

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

View file

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

View file

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

View file

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

View file

@ -25,7 +25,7 @@ impl NormalisationData {
let newpos = file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?;
if newpos != SPOTIFY_NORMALIZATION_HEADER_START_OFFSET {
return Err(Error::failed_precondition(format!(
"NormalisationData::parse_from_ogg seeking to {} but position is now {}",
"NormalisationData::parse_from_file seeking to {} but position is now {}",
SPOTIFY_NORMALIZATION_HEADER_START_OFFSET, newpos
)));
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -89,25 +89,6 @@ pub fn audio_format_mime(format: AudioFileFormat) -> Option<&'static str> {
}
}
/// Get the bitrate of the audio format in kbit/s
pub fn audio_format_bitrate(format: AudioFileFormat) -> Option<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 {
match format {
AudioFileFormat::OGG_VORBIS_96