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));
127 let store = Arc::new(CaptureRefreshToken(captured.clone()));
128 let http = YmusicHttp::with_store(
129 String::new(),
130 String::new(),
131 creds.refresh_token.clone(),
132 std::collections::BTreeSet::new(),
133 std::path::PathBuf::new(),
134 Some(store),
135 );
136 let channel = http.my_channel().await?;
137 let title = channel
138 .snippet
139 .as_ref()
140 .and_then(|s| s.title.as_deref())
141 .unwrap_or("YouTube Music user");
142 let identity = format!("{title} ({})", channel.id);
143 if let Some(rotated) = captured.lock().unwrap().take() {
144 creds.refresh_token = rotated;
145 }
146 Ok(identity)
147 }
148
149 fn store_secrets(creds: &YmusicSecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
150 let legacy_client_id = secrets::account(Self::NAME, "client-id", scope.clone());
156 let legacy_client_secret = secrets::account(Self::NAME, "client-secret", scope.clone());
157 let _ = secrets::delete(&legacy_client_id);
158 let _ = secrets::delete(&legacy_client_secret);
159
160 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
161 secrets::store(&refresh_acct, &creds.refresh_token)?;
162 Ok(vec![SecretRef {
163 label: "refresh token",
164 account: refresh_acct,
165 present: true,
166 }])
167 }
168
169 fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
170 let legacy_client_id = secrets::account(Self::NAME, "client-id", scope.clone());
171 let legacy_client_secret = secrets::account(Self::NAME, "client-secret", scope.clone());
172 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
173 let _ = secrets::delete(&legacy_client_id);
174 let _ = secrets::delete(&legacy_client_secret);
175 secrets::delete(&refresh_acct)?;
176 Ok(vec![SecretRef {
177 label: "refresh token",
178 account: refresh_acct,
179 present: false,
180 }])
181 }
182
183 fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
184 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
185 let refresh_present = secrets::load(&refresh_acct)?.is_some();
186 Ok(vec![SecretRef {
187 label: "refresh token",
188 account: refresh_acct,
189 present: refresh_present,
190 }])
191 }
192
193 fn load_secrets(scope: Scope<'_>) -> Result<Option<YmusicSecrets>> {
194 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
195 let Some(refresh) = secrets::load(&refresh_acct)? else {
196 return Ok(None);
197 };
198 Ok(Some(YmusicSecrets {
199 refresh_token: refresh,
200 }))
201 }
202
203 fn cfg_human(cfg: &YmusicServiceCfg) -> Vec<(&'static str, String)> {
204 let mut out = vec![];
205 if let Some(p) = &cfg.default_playlist {
206 out.push(("playlist", p.clone()));
207 }
208 if let Some(c) = &cfg.self_channel_id {
209 out.push(("channel", c.clone()));
210 }
211 out
212 }
213
214 fn cfg_json(cfg: &YmusicServiceCfg) -> serde_json::Value {
215 serde_json::json!({
216 "default_playlist": cfg.default_playlist,
217 "self_channel_id": cfg.self_channel_id,
218 })
219 }
220
221 fn scopes_of(cfg: &YmusicServiceCfg) -> &[String] {
222 &cfg.scopes
223 }
224
225 fn post_create_hint(_cfg: &YmusicServiceCfg) -> Option<String> {
226 None
227 }
228}
229
230#[async_trait]
231impl CliLifecycle for YmusicLifecycle {
232 type CreateArgs = CreateArgs;
233
234 async fn resolve(
235 args: &CreateArgs,
236 non_interactive: bool,
237 ) -> Result<(YmusicServiceCfg, YmusicSecrets)> {
238 let open_browser = !args.base.no_browser;
239
240 let scopes = resolve_scopes(
241 args.scopes.scopes.as_deref(),
242 DEFAULT_SCOPES,
243 ALL_SCOPES,
244 non_interactive,
245 )?;
246
247 let refresh_token = if let Some(v) = args.refresh_token.clone() {
248 v
249 } else if let Some(env) = args.refresh_token_env.as_deref() {
250 std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()))?
251 } else {
252 resolve_refresh_via_device_flow(open_browser, non_interactive).await?
253 };
254
255 Ok((
256 YmusicServiceCfg {
257 scopes,
258 default_playlist: args.default_playlist.clone(),
259 self_channel_id: args.self_channel.clone(),
260 },
261 YmusicSecrets { refresh_token },
262 ))
263 }
264}
265
266fn theme() -> ColorfulTheme {
271 ColorfulTheme::default()
272}
273
274async fn resolve_refresh_via_device_flow(
278 open_browser: bool,
279 non_interactive: bool,
280) -> Result<String> {
281 if non_interactive {
282 return Err(ZadError::MissingRequired(
283 "--refresh-token or --refresh-token-env (non-interactive mode cannot run the \
284 device-flow prompt)",
285 ));
286 }
287
288 println!();
289 println!(
290 "YouTube Music uses Google's OAuth 2.0 device flow (the same one TV apps use).\n\
291 No client-id or client-secret to configure — zad ships the shared TVHTML5\n\
292 credentials. You'll get a short URL and a 9-character code; visit the URL in\n\
293 any browser (it does not have to be on this machine), enter the code, and\n\
294 approve. This window will keep polling until you finish or the code expires."
295 );
296
297 let want = Confirm::with_theme(&theme())
298 .with_prompt("Continue with the device-flow prompt?")
299 .default(true)
300 .interact()
301 .into_zad()?;
302 if !want {
303 return Err(ZadError::Invalid(
304 "device-flow declined by operator; pass --refresh-token to skip it".into(),
305 ));
306 }
307
308 let cfg = DeviceFlowConfig::default();
309 let tokens = run_device_flow(&cfg, |code| {
310 println!();
311 println!(" Visit: {}", code.verification_url);
312 println!(" Enter: {}", code.user_code);
313 println!();
314 println!(
315 "Waiting up to {}s for approval (polling every {}s)…",
316 code.expires_in, code.interval
317 );
318 if open_browser {
319 let _ = open::that(&code.verification_url);
320 }
321 })
322 .await?;
323
324 tokens.refresh_token.ok_or_else(|| ZadError::Service {
325 name: "ymusic",
326 message: "Google did not return a refresh token from the device flow. Re-run \
327 `zad service create ymusic` to retry."
328 .into(),
329 })
330}
331
332struct CaptureRefreshToken(Arc<Mutex<Option<String>>>);
336
337impl RefreshTokenStore for CaptureRefreshToken {
338 fn store(&self, refresh_token: &str) -> Result<()> {
339 *self.0.lock().unwrap() = Some(refresh_token.to_string());
340 Ok(())
341 }
342}