435 lines
15 KiB
Rust
435 lines
15 KiB
Rust
use std::{fs::File, io::BufReader, path::PathBuf, time::Duration};
|
|
|
|
use anyhow::Error;
|
|
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,
|
|
};
|
|
use tracing::level_filters::LevelFilter;
|
|
use tracing_subscriber::EnvFilter;
|
|
use widevine::{Cdm, Device, Pssh};
|
|
|
|
#[derive(Parser)]
|
|
#[clap(author, version, about)]
|
|
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)]
|
|
enum Commands {
|
|
/// Add an account
|
|
Login,
|
|
/// Remove an account
|
|
Logout {
|
|
/// Account ID to log out
|
|
id: String,
|
|
},
|
|
/// User accounts
|
|
Accounts,
|
|
Track {
|
|
id: String,
|
|
#[clap(long)]
|
|
key: bool,
|
|
#[clap(long)]
|
|
web: bool,
|
|
},
|
|
Album {
|
|
id: String,
|
|
},
|
|
Artist {
|
|
id: String,
|
|
},
|
|
Playlist {
|
|
id: String,
|
|
},
|
|
User {
|
|
id: String,
|
|
},
|
|
Prerelease {
|
|
id: String,
|
|
},
|
|
Ratelimit {
|
|
ids: PathBuf,
|
|
},
|
|
Widevine {
|
|
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())
|
|
.parse("spotifyio=debug,spotifyio_cli=debug")
|
|
.unwrap(),
|
|
)
|
|
.init();
|
|
let cli = Cli::parse();
|
|
|
|
let path = cli.cache.unwrap_or(path!(
|
|
env!("CARGO_MANIFEST_DIR") / ".." / ".." / "data" / "spotifyio.json"
|
|
));
|
|
let app_cache = ApplicationCache::new(path);
|
|
|
|
let scfg = SessionConfig {
|
|
language: cli.lang,
|
|
..Default::default()
|
|
};
|
|
match cli.command {
|
|
Commands::Login => {
|
|
let token = spotifyio::oauth::get_access_token(None).await?;
|
|
let cache = spotifyio::oauth::new_session(&app_cache, &token.access_token).await?;
|
|
let session = Session::new(scfg, cache);
|
|
session
|
|
.connect(AuthCredentials {
|
|
user_id: None,
|
|
auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN,
|
|
auth_data: token.access_token.as_bytes().to_vec(),
|
|
})
|
|
.await?;
|
|
println!("Logged in as <{}>", session.email().unwrap_or_default());
|
|
println!("User ID: {}", session.user_id());
|
|
if session.is_premium() {
|
|
println!("Premium account");
|
|
} else {
|
|
println!("Free account");
|
|
}
|
|
}
|
|
Commands::Logout { id } => {
|
|
app_cache.remove_account(&id)?;
|
|
}
|
|
Commands::Accounts => {
|
|
let accounts = app_cache.accounts();
|
|
println!("{} Spotify account(s)", accounts.len());
|
|
for account in accounts {
|
|
println!(
|
|
"[{}] {}",
|
|
account.user_id,
|
|
account.email.as_deref().unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
Commands::Track { id, key, web } => {
|
|
let pool = SpotifyIoPool::from_app_cache(
|
|
app_cache,
|
|
&scfg,
|
|
&PoolConfig {
|
|
max_sessions: 1,
|
|
..Default::default()
|
|
},
|
|
)?;
|
|
let spotify_id = parse_idstr(&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()
|
|
);
|
|
|
|
if key {
|
|
let spotify_id = TrackId::from_raw(track.gid()).unwrap();
|
|
println!("Track id: {}", spotify_id.id());
|
|
|
|
for file in &track.file {
|
|
println!("{} {:?}", FileId::from_raw(file.file_id())?, file.format());
|
|
}
|
|
|
|
let file = track
|
|
.file
|
|
.iter()
|
|
.find(|file| matches!(file.format(), AudioFileFormat::OGG_VORBIS_160))
|
|
.expect("audio file");
|
|
let file_id = FileId::from_raw(file.file_id())?;
|
|
|
|
let key = pool
|
|
.audio_key()
|
|
.await?
|
|
.request(PlayableId::Track(spotify_id), &file_id)
|
|
.await?;
|
|
println!("Audio key: {}", data_encoding::HEXLOWER.encode(&key.0));
|
|
}
|
|
}
|
|
}
|
|
Commands::Album { id } => {
|
|
let pool = SpotifyIoPool::from_app_cache(
|
|
app_cache,
|
|
&scfg,
|
|
&PoolConfig {
|
|
max_sessions: 1,
|
|
..Default::default()
|
|
},
|
|
)?;
|
|
|
|
let id = parse_idstr(&id)?;
|
|
let album_et = pool.spclient()?.pb_album(id, None).await?;
|
|
|
|
tracing::info!("ETag: {}", album_et.etag.unwrap_or_default());
|
|
println!(
|
|
"{}",
|
|
protobuf_json_mapping::print_to_string(&album_et.data).unwrap()
|
|
);
|
|
}
|
|
Commands::Artist { id } => {
|
|
let pool = SpotifyIoPool::from_app_cache(
|
|
app_cache,
|
|
&scfg,
|
|
&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 = 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 } => {
|
|
let pool = SpotifyIoPool::from_app_cache(
|
|
app_cache,
|
|
&scfg,
|
|
&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());
|
|
}
|
|
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
|
|
.lines()
|
|
.filter_map(|line| parse_idstr(line).ok())
|
|
.collect::<Vec<_>>();
|
|
|
|
let client = pool.spclient()?;
|
|
|
|
stream::iter(ids)
|
|
.map(|id| {
|
|
// let pool = pool.clone();
|
|
let client = client.clone();
|
|
async move {
|
|
let artist = client.gql_artist_overview(id).await?;
|
|
if let Some(p) = artist.pre_release {
|
|
let prerelease = client.gql_prerelease(p.uri).await?;
|
|
tracing::info!(
|
|
"Prerelease: [{}] {}",
|
|
prerelease.uri,
|
|
prerelease.pre_release_content.name
|
|
);
|
|
}
|
|
|
|
// let concerts = client.gql_artist_concerts(&id).await?;
|
|
/*
|
|
for c in concerts {
|
|
let res = client.gql_concert(&c.uri).await;
|
|
match res {
|
|
Ok(concert) => {
|
|
let loc = concert.location.map(|l| l.name).unwrap_or_default();
|
|
tracing::info!("Concert: {}, {}", concert.title, loc);
|
|
}
|
|
Err(e) => if matches!(e.kind, ErrorKind::NotFound) {
|
|
tracing::warn!("Concert: {} not found", id);
|
|
} else {
|
|
return Err(e)
|
|
},
|
|
}
|
|
}
|
|
*/
|
|
// match &res {
|
|
// Ok(a) => tracing::info!("OK: [{}] {}", id, a.profile.name),
|
|
// Err(e) => tracing::error!("Error: {e}"),
|
|
// }
|
|
// res
|
|
Ok::<_, spotifyio::Error>(())
|
|
}
|
|
})
|
|
.buffered(16)
|
|
.try_collect::<Vec<_>>()
|
|
.await?;
|
|
}
|
|
Commands::Widevine { id } => {
|
|
let pool = SpotifyIoPool::from_app_cache(
|
|
app_cache,
|
|
&scfg,
|
|
&PoolConfig {
|
|
max_sessions: 1,
|
|
..Default::default()
|
|
},
|
|
)?;
|
|
let spid = parse_idstr(&id)?;
|
|
let track = pool.spclient()?.pb_track(spid, None).await?.data;
|
|
let mp4_file = track
|
|
.file
|
|
.iter()
|
|
.find(|f| matches!(f.format(), AudioFileFormat::MP4_128))
|
|
.expect("mp4 file");
|
|
|
|
let file_id = FileId::from_raw(mp4_file.file_id())?;
|
|
let cdn_url = CdnUrl::new(file_id).resolve_audio(pool.session()?).await?;
|
|
let url = cdn_url.try_get_url()?;
|
|
let seektable = pool.spclient()?.get_seektable(&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 certificate = std::fs::read("/home/thetadev/Documents/Programmieren/Rust/widevine-rs/crates/widevine/testfiles/application-certificate").unwrap();
|
|
|
|
let cdm = Cdm::new(device);
|
|
let pssh = Pssh::from_b64(&seektable.pssh)?;
|
|
let lic = cdm
|
|
.open()
|
|
.set_service_certificate(&certificate)?
|
|
.get_license_request(pssh, widevine::LicenseType::STREAMING)?;
|
|
let license_message = pool
|
|
.spclient()?
|
|
.get_widevine_license(lic.challenge()?, false)
|
|
.await?;
|
|
let keys = lic.get_keys(&license_message)?;
|
|
|
|
println!("[{}] {}", id, track.name());
|
|
println!("{}", url.0);
|
|
dbg!(keys);
|
|
}
|
|
Commands::Upkeep => {
|
|
let pool = SpotifyIoPool::from_app_cache(
|
|
app_cache,
|
|
&scfg,
|
|
&PoolConfig {
|
|
max_sessions: 1,
|
|
..Default::default()
|
|
},
|
|
)?;
|
|
pool.connect().await.unwrap();
|
|
|
|
let stdout = console::Term::buffered_stdout();
|
|
let spotify_id = parse_idstr::<TrackId>("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(key) => {
|
|
println!("Audio key: {}", data_encoding::HEXLOWER.encode(&key.0))
|
|
}
|
|
Err(e) => tracing::error!("{e}"),
|
|
},
|
|
Err(e) => tracing::error!("{e}"),
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|