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, } #[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::>(); 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::>() .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::("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::(&b64)?; println!("{id}"); } } Ok(()) } fn parse_idstr<'a, T: IdConstruct<'a>>(s: &'a str) -> Result { 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) } }