use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::cli::DialoguerExt;
use async_trait::async_trait;
use clap::Args;
use dialoguer::{Confirm, Input, theme::ColorfulTheme};
use crate::cli::lifecycle::{
CliLifecycle, CreateArgsBase, CreateArgsLike, LifecycleService, ScopesArg, SecretRef,
resolve_scopes,
};
use zad::config::{ProjectConfig, SpotifyServiceCfg};
use zad::error::{Result, ZadError};
use zad::oauth::{LoopbackConfig, RedirectScheme, RefreshTokenStore, run_loopback_flow};
use zad::secrets::{self, Scope};
use zad::service::spotify::{AUTH_URL, SpotifyHttp, TOKEN_URL, spotify_scopes_for};
const DEFAULT_SCOPES: &[&str] = &[
"search",
"playlists.read",
"playlists.write",
"library.read",
];
const ALL_SCOPES: &[&str] = &[
"search",
"playlists.read",
"playlists.write",
"library.read",
"library.write",
];
const SPOTIFY_DASHBOARD_URL: &str = "https://developer.spotify.com/dashboard";
const LOOPBACK_TIMEOUT: Duration = Duration::from_secs(120);
pub struct SpotifySecrets {
pub client_id: String,
pub refresh_token: String,
}
#[derive(Debug, Args)]
pub struct CreateArgs {
#[command(flatten)]
pub base: CreateArgsBase,
#[command(flatten)]
pub scopes: ScopesArg,
#[arg(long)]
pub client_id: Option<String>,
#[arg(long, conflicts_with = "client_id")]
pub client_id_env: Option<String>,
#[arg(long, conflicts_with = "refresh_token_env")]
pub refresh_token: Option<String>,
#[arg(long, conflicts_with = "refresh_token")]
pub refresh_token_env: Option<String>,
#[arg(long)]
pub default_playlist: Option<String>,
}
impl CreateArgsLike for CreateArgs {
fn base(&self) -> &CreateArgsBase {
&self.base
}
}
pub struct SpotifyLifecycle;
#[async_trait]
impl LifecycleService for SpotifyLifecycle {
const NAME: &'static str = "spotify";
const DISPLAY: &'static str = "Spotify";
type Cfg = SpotifyServiceCfg;
type Secrets = SpotifySecrets;
fn enable_in_project(cfg: &mut ProjectConfig) {
cfg.enable_spotify();
}
fn disable_in_project(cfg: &mut ProjectConfig) {
cfg.disable_spotify();
}
async fn validate(_cfg: &SpotifyServiceCfg, creds: &mut SpotifySecrets) -> Result<String> {
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let store = Arc::new(CaptureRefreshToken(captured.clone()));
let http = SpotifyHttp::with_store(
creds.client_id.clone(),
creds.refresh_token.clone(),
std::collections::BTreeSet::new(),
std::path::PathBuf::new(),
Some(store),
);
let me = http.me().await?;
if let Some(rotated) = captured.lock().unwrap().take() {
creds.refresh_token = rotated;
}
Ok(me.display_name.unwrap_or(me.id))
}
fn store_secrets(creds: &SpotifySecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
secrets::store(&client_id_acct, &creds.client_id)?;
secrets::store(&refresh_acct, &creds.refresh_token)?;
Ok(vec![
SecretRef {
label: "client id",
account: client_id_acct,
present: true,
},
SecretRef {
label: "refresh token",
account: refresh_acct,
present: true,
},
])
}
fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
secrets::delete(&client_id_acct)?;
secrets::delete(&refresh_acct)?;
Ok(vec![
SecretRef {
label: "client id",
account: client_id_acct,
present: false,
},
SecretRef {
label: "refresh token",
account: refresh_acct,
present: false,
},
])
}
fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
let client_id_present = secrets::load(&client_id_acct)?.is_some();
let refresh_present = secrets::load(&refresh_acct)?.is_some();
Ok(vec![
SecretRef {
label: "client id",
account: client_id_acct,
present: client_id_present,
},
SecretRef {
label: "refresh token",
account: refresh_acct,
present: refresh_present,
},
])
}
fn load_secrets(scope: Scope<'_>) -> Result<Option<SpotifySecrets>> {
let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
let (Some(id), Some(refresh)) = (
secrets::load(&client_id_acct)?,
secrets::load(&refresh_acct)?,
) else {
return Ok(None);
};
Ok(Some(SpotifySecrets {
client_id: id,
refresh_token: refresh,
}))
}
fn cfg_human(cfg: &SpotifyServiceCfg) -> Vec<(&'static str, String)> {
let mut out = vec![];
if let Some(p) = &cfg.default_playlist {
out.push(("playlist", p.clone()));
}
out
}
fn cfg_json(cfg: &SpotifyServiceCfg) -> serde_json::Value {
serde_json::json!({
"default_playlist": cfg.default_playlist,
})
}
fn scopes_of(cfg: &SpotifyServiceCfg) -> &[String] {
&cfg.scopes
}
fn post_create_hint(_cfg: &SpotifyServiceCfg) -> Option<String> {
None
}
}
#[async_trait]
impl CliLifecycle for SpotifyLifecycle {
type CreateArgs = CreateArgs;
async fn resolve(
args: &CreateArgs,
non_interactive: bool,
) -> Result<(SpotifyServiceCfg, SpotifySecrets)> {
let open_browser = !args.base.no_browser;
let scopes = resolve_scopes(
args.scopes.scopes.as_deref(),
DEFAULT_SCOPES,
ALL_SCOPES,
non_interactive,
)?;
let client_id = resolve_client_id(
args.client_id.as_deref(),
args.client_id_env.as_deref(),
open_browser,
non_interactive,
)?;
let refresh_token = if let Some(v) = args.refresh_token.clone() {
v
} else if let Some(env) = args.refresh_token_env.as_deref() {
std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()))?
} else {
resolve_refresh_via_loopback(&client_id, &scopes, open_browser, non_interactive).await?
};
Ok((
SpotifyServiceCfg {
scopes,
default_playlist: args.default_playlist.clone(),
},
SpotifySecrets {
client_id,
refresh_token,
},
))
}
}
fn theme() -> ColorfulTheme {
ColorfulTheme::default()
}
fn resolve_client_id(
flag: Option<&str>,
env_flag: Option<&str>,
open_browser: bool,
non_interactive: bool,
) -> Result<String> {
if let Some(env) = env_flag {
return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
}
if let Some(v) = flag {
return Ok(v.to_string());
}
if non_interactive {
return Err(ZadError::MissingRequired("--client-id or --client-id-env"));
}
println!();
println!("Spotify uses OAuth 2.0 (PKCE public client). You need a Spotify app:");
println!(" 1. Open the Spotify Developer Dashboard:");
println!(" {SPOTIFY_DASHBOARD_URL}");
println!(" 2. Click \"Create app\". Name and description are arbitrary.");
println!(" 3. Under \"Redirect URIs\", add `https://127.0.0.1` and save.");
println!(" (Spotify dropped HTTP for OAuth redirects — zad terminates TLS on the loopback");
println!(" listener with a per-session self-signed cert; your browser will show a");
println!(
" \"connection not private\" warning the first time you authorize — click through it.)"
);
println!(" 4. Copy the Client ID from the app's Settings page back here.");
println!(" (Spotify also shows a Client Secret — you do NOT need it for PKCE.)");
if open_browser {
let _ = open::that(SPOTIFY_DASHBOARD_URL);
}
let v: String = Input::with_theme(&theme())
.with_prompt("Spotify Client ID")
.interact_text()
.into_zad()?;
Ok(v.trim().to_string())
}
async fn resolve_refresh_via_loopback(
client_id: &str,
zad_scopes: &[String],
open_browser: bool,
non_interactive: bool,
) -> Result<String> {
if non_interactive {
return Err(ZadError::MissingRequired(
"--refresh-token or --refresh-token-env (non-interactive mode cannot open a browser)",
));
}
println!();
println!("No refresh token provided — starting the browser OAuth flow.");
println!(
"Make sure your Spotify app's \"Redirect URIs\" list includes `https://127.0.0.1` \
(Spotify no longer accepts http://; the loopback listener picks a random port and \
Spotify accepts any port on 127.0.0.1 once the host is registered). zad terminates \
TLS on the loopback with a per-session self-signed cert, so your browser will show a \
\"connection not private\" warning — click through it to finish authorization."
);
let want = Confirm::with_theme(&theme())
.with_prompt("Continue with the browser flow?")
.default(true)
.interact()
.into_zad()?;
if !want {
return Err(ZadError::Invalid(
"browser OAuth flow declined by operator; pass --refresh-token to skip it".into(),
));
}
let provider_scopes = spotify_scopes_for(zad_scopes);
let cfg = LoopbackConfig {
service_name: "spotify",
display_name: "Spotify",
auth_url: AUTH_URL.to_string(),
token_url: TOKEN_URL.to_string(),
client_id: client_id.to_string(),
client_secret: None,
scopes: provider_scopes,
extra_auth_params: vec![("show_dialog".into(), "true".into())],
timeout: LOOPBACK_TIMEOUT,
redirect_scheme: RedirectScheme::Https,
};
let tokens = run_loopback_flow(&cfg, open_browser).await?;
tokens.refresh_token.ok_or_else(|| ZadError::Service {
name: "spotify",
message: "Spotify did not return a refresh token. Re-run \
`zad service create spotify` to retry the consent flow."
.into(),
})
}
struct CaptureRefreshToken(Arc<Mutex<Option<String>>>);
impl RefreshTokenStore for CaptureRefreshToken {
fn store(&self, refresh_token: &str) -> Result<()> {
*self.0.lock().unwrap() = Some(refresh_token.to_string());
Ok(())
}
}