spotifyio/crates/cli/src/main.rs

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)
}
}