1use crate::cli::DialoguerExt;
17use async_trait::async_trait;
18use clap::Args;
19use dialoguer::{Confirm, theme::ColorfulTheme};
20
21use crate::cli::lifecycle::{
22 CliLifecycle, CreateArgsBase, CreateArgsLike, LifecycleService, ScopesArg, SecretRef,
23 resolve_scopes,
24};
25use std::sync::{Arc, Mutex};
26
27use zad::config::{ProjectConfig, YmusicServiceCfg};
28use zad::error::{Result, ZadError};
29use zad::oauth::RefreshTokenStore;
30use zad::secrets::{self, Scope};
31use zad::service::ymusic::YmusicHttp;
32use zad::service::ymusic::oauth_device::{DeviceFlowConfig, run_device_flow};
33
34const DEFAULT_SCOPES: &[&str] = &[
35 "search",
36 "playlists.read",
37 "playlists.write",
38 "library.read",
39];
40const ALL_SCOPES: &[&str] = &[
41 "search",
42 "playlists.read",
43 "playlists.write",
44 "library.read",
45 "library.write",
46];
47
48pub struct YmusicSecrets {
57 pub refresh_token: String,
58}
59
60#[derive(Debug, Args)]
65pub struct CreateArgs {
66 #[command(flatten)]
67 pub base: CreateArgsBase,
68 #[command(flatten)]
69 pub scopes: ScopesArg,
70
71 #[arg(long, conflicts_with = "refresh_token_env")]
75 pub refresh_token: Option<String>,
76
77 #[arg(long, conflicts_with = "refresh_token")]
79 pub refresh_token_env: Option<String>,
80
81 #[arg(long)]
84 pub default_playlist: Option<String>,
85
86 #[arg(long)]
90 pub self_channel: Option<String>,
91}
92
93impl CreateArgsLike for CreateArgs {
94 fn base(&self) -> &CreateArgsBase {
95 &self.base
96 }
97}
98
99pub struct YmusicLifecycle;
104
105#[async_trait]
106impl LifecycleService for YmusicLifecycle {
107 const NAME: &'static str = "ymusic";
108 const DISPLAY: &'static str = "YouTube Music";
109 type Cfg = YmusicServiceCfg;
110 type Secrets = YmusicSecrets;
111
112 fn enable_in_project(cfg: &mut ProjectConfig) {
113 cfg.enable_ymusic();
114 }
115
116 fn disable_in_project(cfg: &mut ProjectConfig) {
117 cfg.disable_ymusic();
118 }
119
120 async fn validate(_cfg: &YmusicServiceCfg, creds: &mut YmusicSecrets) -> Result<String> {
121 let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
122 let store = Arc::new(CaptureRefreshToken(captured.clone()));
123 let http = YmusicHttp::with_store(
124 String::new(),
125 String::new(),
126 creds.refresh_token.clone(),
127 std::collections::BTreeSet::new(),
128 std::path::PathBuf::new(),
129 Some(store),
130 );
131 let info = http.userinfo().await?;
132 let email = info.email.unwrap_or_else(|| "<unknown>".into());
133 let identity = match http.my_channel().await {
134 Ok(c) => {
135 let title = c
136 .snippet
137 .as_ref()
138 .and_then(|s| s.title.as_deref())
139 .unwrap_or(email.as_str())
140 .to_string();
141 format!("{title} ({email})")
142 }
143 Err(_) => format!("{email} (no YouTube channel)"),
144 };
145 if let Some(rotated) = captured.lock().unwrap().take() {
146 creds.refresh_token = rotated;
147 }
148 Ok(identity)
149 }
150
151 fn store_secrets(creds: &YmusicSecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
152 let legacy_client_id = secrets::account(Self::NAME, "client-id", scope.clone());
158 let legacy_client_secret = secrets::account(Self::NAME, "client-secret", scope.clone());
159 let _ = secrets::delete(&legacy_client_id);
160 let _ = secrets::delete(&legacy_client_secret);
161
162 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
163 secrets::store(&refresh_acct, &creds.refresh_token)?;
164 Ok(vec![SecretRef {
165 label: "refresh token",
166 account: refresh_acct,
167 present: true,
168 }])
169 }
170
171 fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
172 let legacy_client_id = secrets::account(Self::NAME, "client-id", scope.clone());
173 let legacy_client_secret = secrets::account(Self::NAME, "client-secret", scope.clone());
174 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
175 let _ = secrets::delete(&legacy_client_id);
176 let _ = secrets::delete(&legacy_client_secret);
177 secrets::delete(&refresh_acct)?;
178 Ok(vec![SecretRef {
179 label: "refresh token",
180 account: refresh_acct,
181 present: false,
182 }])
183 }
184
185 fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
186 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
187 let refresh_present = secrets::load(&refresh_acct)?.is_some();
188 Ok(vec![SecretRef {
189 label: "refresh token",
190 account: refresh_acct,
191 present: refresh_present,
192 }])
193 }
194
195 fn load_secrets(scope: Scope<'_>) -> Result<Option<YmusicSecrets>> {
196 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
197 let Some(refresh) = secrets::load(&refresh_acct)? else {
198 return Ok(None);
199 };
200 Ok(Some(YmusicSecrets {
201 refresh_token: refresh,
202 }))
203 }
204
205 fn cfg_human(cfg: &YmusicServiceCfg) -> Vec<(&'static str, String)> {
206 let mut out = vec![];
207 if let Some(p) = &cfg.default_playlist {
208 out.push(("playlist", p.clone()));
209 }
210 if let Some(c) = &cfg.self_channel_id {
211 out.push(("channel", c.clone()));
212 }
213 out
214 }
215
216 fn cfg_json(cfg: &YmusicServiceCfg) -> serde_json::Value {
217 serde_json::json!({
218 "default_playlist": cfg.default_playlist,
219 "self_channel_id": cfg.self_channel_id,
220 })
221 }
222
223 fn scopes_of(cfg: &YmusicServiceCfg) -> &[String] {
224 &cfg.scopes
225 }
226
227 fn post_create_hint(_cfg: &YmusicServiceCfg) -> Option<String> {
228 None
229 }
230}
231
232#[async_trait]
233impl CliLifecycle for YmusicLifecycle {
234 type CreateArgs = CreateArgs;
235
236 async fn resolve(
237 args: &CreateArgs,
238 non_interactive: bool,
239 ) -> Result<(YmusicServiceCfg, YmusicSecrets)> {
240 let open_browser = !args.base.no_browser;
241
242 let scopes = resolve_scopes(
243 args.scopes.scopes.as_deref(),
244 DEFAULT_SCOPES,
245 ALL_SCOPES,
246 non_interactive,
247 )?;
248
249 let refresh_token = if let Some(v) = args.refresh_token.clone() {
250 v
251 } else if let Some(env) = args.refresh_token_env.as_deref() {
252 std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()))?
253 } else {
254 resolve_refresh_via_device_flow(open_browser, non_interactive).await?
255 };
256
257 Ok((
258 YmusicServiceCfg {
259 scopes,
260 default_playlist: args.default_playlist.clone(),
261 self_channel_id: args.self_channel.clone(),
262 },
263 YmusicSecrets { refresh_token },
264 ))
265 }
266}
267
268fn theme() -> ColorfulTheme {
273 ColorfulTheme::default()
274}
275
276async fn resolve_refresh_via_device_flow(
280 open_browser: bool,
281 non_interactive: bool,
282) -> Result<String> {
283 if non_interactive {
284 return Err(ZadError::MissingRequired(
285 "--refresh-token or --refresh-token-env (non-interactive mode cannot run the \
286 device-flow prompt)",
287 ));
288 }
289
290 println!();
291 println!(
292 "YouTube Music uses Google's OAuth 2.0 device flow (the same one TV apps use).\n\
293 No client-id or client-secret to configure — zad ships the shared TVHTML5\n\
294 credentials. You'll get a short URL and a 9-character code; visit the URL in\n\
295 any browser (it does not have to be on this machine), enter the code, and\n\
296 approve. This window will keep polling until you finish or the code expires."
297 );
298
299 let want = Confirm::with_theme(&theme())
300 .with_prompt("Continue with the device-flow prompt?")
301 .default(true)
302 .interact()
303 .into_zad()?;
304 if !want {
305 return Err(ZadError::Invalid(
306 "device-flow declined by operator; pass --refresh-token to skip it".into(),
307 ));
308 }
309
310 let cfg = DeviceFlowConfig::default();
311 let tokens = run_device_flow(&cfg, |code| {
312 println!();
313 println!(" Visit: {}", code.verification_url);
314 println!(" Enter: {}", code.user_code);
315 println!();
316 println!(
317 "Waiting up to {}s for approval (polling every {}s)…",
318 code.expires_in, code.interval
319 );
320 if open_browser {
321 let _ = open::that(&code.verification_url);
322 }
323 })
324 .await?;
325
326 tokens.refresh_token.ok_or_else(|| ZadError::Service {
327 name: "ymusic",
328 message: "Google did not return a refresh token from the device flow. Re-run \
329 `zad service create ymusic` to retry."
330 .into(),
331 })
332}
333
334struct CaptureRefreshToken(Arc<Mutex<Option<String>>>);
338
339impl RefreshTokenStore for CaptureRefreshToken {
340 fn store(&self, refresh_token: &str) -> Result<()> {
341 *self.0.lock().unwrap() = Some(refresh_token.to_string());
342 Ok(())
343 }
344}