1use std::sync::{Arc, Mutex};
15use std::time::Duration;
16
17use crate::cli::DialoguerExt;
18use async_trait::async_trait;
19use clap::Args;
20use dialoguer::{Confirm, Input, theme::ColorfulTheme};
21
22use crate::cli::lifecycle::{
23 CliLifecycle, CreateArgsBase, CreateArgsLike, LifecycleService, ScopesArg, SecretRef,
24 resolve_scopes,
25};
26use zad::config::{ProjectConfig, SpotifyServiceCfg};
27use zad::error::{Result, ZadError};
28use zad::oauth::{LoopbackConfig, RedirectScheme, RefreshTokenStore, run_loopback_flow};
29use zad::secrets::{self, Scope};
30use zad::service::spotify::{AUTH_URL, SpotifyHttp, TOKEN_URL, spotify_scopes_for};
31
32const DEFAULT_SCOPES: &[&str] = &[
33 "search",
34 "playlists.read",
35 "playlists.write",
36 "library.read",
37];
38const ALL_SCOPES: &[&str] = &[
39 "search",
40 "playlists.read",
41 "playlists.write",
42 "library.read",
43 "library.write",
44];
45
46const SPOTIFY_DASHBOARD_URL: &str = "https://developer.spotify.com/dashboard";
48
49const LOOPBACK_TIMEOUT: Duration = Duration::from_secs(120);
53
54pub struct SpotifySecrets {
64 pub client_id: String,
65 pub refresh_token: String,
66}
67
68#[derive(Debug, Args)]
73pub struct CreateArgs {
74 #[command(flatten)]
75 pub base: CreateArgsBase,
76 #[command(flatten)]
77 pub scopes: ScopesArg,
78
79 #[arg(long)]
83 pub client_id: Option<String>,
84
85 #[arg(long, conflicts_with = "client_id")]
87 pub client_id_env: Option<String>,
88
89 #[arg(long, conflicts_with = "refresh_token_env")]
93 pub refresh_token: Option<String>,
94
95 #[arg(long, conflicts_with = "refresh_token")]
97 pub refresh_token_env: Option<String>,
98
99 #[arg(long)]
103 pub default_playlist: Option<String>,
104}
105
106impl CreateArgsLike for CreateArgs {
107 fn base(&self) -> &CreateArgsBase {
108 &self.base
109 }
110}
111
112pub struct SpotifyLifecycle;
117
118#[async_trait]
119impl LifecycleService for SpotifyLifecycle {
120 const NAME: &'static str = "spotify";
121 const DISPLAY: &'static str = "Spotify";
122 type Cfg = SpotifyServiceCfg;
123 type Secrets = SpotifySecrets;
124
125 fn enable_in_project(cfg: &mut ProjectConfig) {
126 cfg.enable_spotify();
127 }
128
129 fn disable_in_project(cfg: &mut ProjectConfig) {
130 cfg.disable_spotify();
131 }
132
133 async fn validate(_cfg: &SpotifyServiceCfg, creds: &mut SpotifySecrets) -> Result<String> {
134 let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
144 let store = Arc::new(CaptureRefreshToken(captured.clone()));
145 let http = SpotifyHttp::with_store(
146 creds.client_id.clone(),
147 creds.refresh_token.clone(),
148 std::collections::BTreeSet::new(),
149 std::path::PathBuf::new(),
150 Some(store),
151 );
152 let me = http.me().await?;
153 if let Some(rotated) = captured.lock().unwrap().take() {
154 creds.refresh_token = rotated;
155 }
156 Ok(me.display_name.unwrap_or(me.id))
157 }
158
159 fn store_secrets(creds: &SpotifySecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
160 let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
161 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
162 secrets::store(&client_id_acct, &creds.client_id)?;
163 secrets::store(&refresh_acct, &creds.refresh_token)?;
164 Ok(vec![
165 SecretRef {
166 label: "client id",
167 account: client_id_acct,
168 present: true,
169 },
170 SecretRef {
171 label: "refresh token",
172 account: refresh_acct,
173 present: true,
174 },
175 ])
176 }
177
178 fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
179 let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
180 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
181 secrets::delete(&client_id_acct)?;
182 secrets::delete(&refresh_acct)?;
183 Ok(vec![
184 SecretRef {
185 label: "client id",
186 account: client_id_acct,
187 present: false,
188 },
189 SecretRef {
190 label: "refresh token",
191 account: refresh_acct,
192 present: false,
193 },
194 ])
195 }
196
197 fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
198 let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
199 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
200 let client_id_present = secrets::load(&client_id_acct)?.is_some();
201 let refresh_present = secrets::load(&refresh_acct)?.is_some();
202 Ok(vec![
203 SecretRef {
204 label: "client id",
205 account: client_id_acct,
206 present: client_id_present,
207 },
208 SecretRef {
209 label: "refresh token",
210 account: refresh_acct,
211 present: refresh_present,
212 },
213 ])
214 }
215
216 fn load_secrets(scope: Scope<'_>) -> Result<Option<SpotifySecrets>> {
217 let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
218 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
219 let (Some(id), Some(refresh)) = (
220 secrets::load(&client_id_acct)?,
221 secrets::load(&refresh_acct)?,
222 ) else {
223 return Ok(None);
224 };
225 Ok(Some(SpotifySecrets {
226 client_id: id,
227 refresh_token: refresh,
228 }))
229 }
230
231 fn cfg_human(cfg: &SpotifyServiceCfg) -> Vec<(&'static str, String)> {
232 let mut out = vec![];
233 if let Some(p) = &cfg.default_playlist {
234 out.push(("playlist", p.clone()));
235 }
236 out
237 }
238
239 fn cfg_json(cfg: &SpotifyServiceCfg) -> serde_json::Value {
240 serde_json::json!({
241 "default_playlist": cfg.default_playlist,
242 })
243 }
244
245 fn scopes_of(cfg: &SpotifyServiceCfg) -> &[String] {
246 &cfg.scopes
247 }
248
249 fn post_create_hint(_cfg: &SpotifyServiceCfg) -> Option<String> {
250 None
251 }
252}
253
254#[async_trait]
255impl CliLifecycle for SpotifyLifecycle {
256 type CreateArgs = CreateArgs;
257
258 async fn resolve(
259 args: &CreateArgs,
260 non_interactive: bool,
261 ) -> Result<(SpotifyServiceCfg, SpotifySecrets)> {
262 let open_browser = !args.base.no_browser;
263
264 let scopes = resolve_scopes(
265 args.scopes.scopes.as_deref(),
266 DEFAULT_SCOPES,
267 ALL_SCOPES,
268 non_interactive,
269 )?;
270
271 let client_id = resolve_client_id(
272 args.client_id.as_deref(),
273 args.client_id_env.as_deref(),
274 open_browser,
275 non_interactive,
276 )?;
277
278 let refresh_token = if let Some(v) = args.refresh_token.clone() {
279 v
280 } else if let Some(env) = args.refresh_token_env.as_deref() {
281 std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()))?
282 } else {
283 resolve_refresh_via_loopback(&client_id, &scopes, open_browser, non_interactive).await?
284 };
285
286 Ok((
287 SpotifyServiceCfg {
288 scopes,
289 default_playlist: args.default_playlist.clone(),
290 },
291 SpotifySecrets {
292 client_id,
293 refresh_token,
294 },
295 ))
296 }
297}
298
299fn theme() -> ColorfulTheme {
304 ColorfulTheme::default()
305}
306
307fn resolve_client_id(
308 flag: Option<&str>,
309 env_flag: Option<&str>,
310 open_browser: bool,
311 non_interactive: bool,
312) -> Result<String> {
313 if let Some(env) = env_flag {
314 return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
315 }
316 if let Some(v) = flag {
317 return Ok(v.to_string());
318 }
319 if non_interactive {
320 return Err(ZadError::MissingRequired("--client-id or --client-id-env"));
321 }
322
323 println!();
324 println!("Spotify uses OAuth 2.0 (PKCE public client). You need a Spotify app:");
325 println!(" 1. Open the Spotify Developer Dashboard:");
326 println!(" {SPOTIFY_DASHBOARD_URL}");
327 println!(" 2. Click \"Create app\". Name and description are arbitrary.");
328 println!(" 3. Under \"Redirect URIs\", add `https://127.0.0.1` and save.");
329 println!(" (Spotify dropped HTTP for OAuth redirects — zad terminates TLS on the loopback");
330 println!(" listener with a per-session self-signed cert; your browser will show a");
331 println!(
332 " \"connection not private\" warning the first time you authorize — click through it.)"
333 );
334 println!(" 4. Copy the Client ID from the app's Settings page back here.");
335 println!(" (Spotify also shows a Client Secret — you do NOT need it for PKCE.)");
336 if open_browser {
337 let _ = open::that(SPOTIFY_DASHBOARD_URL);
338 }
339
340 let v: String = Input::with_theme(&theme())
341 .with_prompt("Spotify Client ID")
342 .interact_text()
343 .into_zad()?;
344 Ok(v.trim().to_string())
345}
346
347async fn resolve_refresh_via_loopback(
351 client_id: &str,
352 zad_scopes: &[String],
353 open_browser: bool,
354 non_interactive: bool,
355) -> Result<String> {
356 if non_interactive {
357 return Err(ZadError::MissingRequired(
358 "--refresh-token or --refresh-token-env (non-interactive mode cannot open a browser)",
359 ));
360 }
361
362 println!();
363 println!("No refresh token provided — starting the browser OAuth flow.");
364 println!(
365 "Make sure your Spotify app's \"Redirect URIs\" list includes `https://127.0.0.1` \
366 (Spotify no longer accepts http://; the loopback listener picks a random port and \
367 Spotify accepts any port on 127.0.0.1 once the host is registered). zad terminates \
368 TLS on the loopback with a per-session self-signed cert, so your browser will show a \
369 \"connection not private\" warning — click through it to finish authorization."
370 );
371 let want = Confirm::with_theme(&theme())
372 .with_prompt("Continue with the browser flow?")
373 .default(true)
374 .interact()
375 .into_zad()?;
376 if !want {
377 return Err(ZadError::Invalid(
378 "browser OAuth flow declined by operator; pass --refresh-token to skip it".into(),
379 ));
380 }
381
382 let provider_scopes = spotify_scopes_for(zad_scopes);
383 let cfg = LoopbackConfig {
384 service_name: "spotify",
385 display_name: "Spotify",
386 auth_url: AUTH_URL.to_string(),
387 token_url: TOKEN_URL.to_string(),
388 client_id: client_id.to_string(),
389 client_secret: None,
390 scopes: provider_scopes,
391 extra_auth_params: vec![("show_dialog".into(), "true".into())],
396 timeout: LOOPBACK_TIMEOUT,
397 redirect_scheme: RedirectScheme::Https,
402 };
403 let tokens = run_loopback_flow(&cfg, open_browser).await?;
404 tokens.refresh_token.ok_or_else(|| ZadError::Service {
405 name: "spotify",
406 message: "Spotify did not return a refresh token. Re-run \
407 `zad service create spotify` to retry the consent flow."
408 .into(),
409 })
410}
411
412struct CaptureRefreshToken(Arc<Mutex<Option<String>>>);
420
421impl RefreshTokenStore for CaptureRefreshToken {
422 fn store(&self, refresh_token: &str) -> Result<()> {
423 *self.0.lock().unwrap() = Some(refresh_token.to_string());
424 Ok(())
425 }
426}