Skip to main content

zad_cli/cli/
service_ymusic.rs

1//! YouTube Music's plug-in to the generic service lifecycle.
2//!
3//! YouTube Music's runtime client talks to the InnerTube backend
4//! under `music.youtube.com/youtubei/v1`. Authentication is OAuth
5//! 2.0 device flow (RFC 8628) against Google's TVHTML5 client —
6//! there is no per-operator OAuth client to register. The lifecycle
7//! collects a refresh token by walking the user through the device
8//! flow once; subsequent CLI runs mint access tokens from the
9//! refresh token without further interaction. The generic plumbing
10//! (flag parsing, path resolution, JSON envelopes, human banners,
11//! keychain I/O sequencing) lives in `src/cli/lifecycle.rs` and is
12//! shared with every other service.
13//!
14//! See `docs/services.md#adding-a-new-service` for the full recipe.
15
16use 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
48// ---------------------------------------------------------------------------
49// credential shape
50// ---------------------------------------------------------------------------
51
52/// YouTube Music's credential shape. The device-flow refresh token
53/// is the only per-user secret; the OAuth client_id / client_secret
54/// are TVHTML5 constants shared across every install (see
55/// `zad::service::ymusic::oauth_device`) and therefore not stored.
56pub struct YmusicSecrets {
57    pub refresh_token: String,
58}
59
60// ---------------------------------------------------------------------------
61// `zad service create ymusic` args
62// ---------------------------------------------------------------------------
63
64#[derive(Debug, Args)]
65pub struct CreateArgs {
66    #[command(flatten)]
67    pub base: CreateArgsBase,
68    #[command(flatten)]
69    pub scopes: ScopesArg,
70
71    /// Pre-minted OAuth refresh token. When provided, zad skips the
72    /// device-flow prompt and stores the token verbatim. Useful for
73    /// CI and for operators who already minted one elsewhere.
74    #[arg(long, conflicts_with = "refresh_token_env")]
75    pub refresh_token: Option<String>,
76
77    /// Read `--refresh-token` from this environment variable instead.
78    #[arg(long, conflicts_with = "refresh_token")]
79    pub refresh_token_env: Option<String>,
80
81    /// Optional default playlist for verbs that omit `--playlist`.
82    /// Accepts a YouTube playlist ID (`PL…`) or a directory alias.
83    #[arg(long)]
84    pub default_playlist: Option<String>,
85
86    /// The authenticated user's YouTube channel ID (`UC…`). Normally
87    /// captured during `validate` — pass this only to pre-seed the
88    /// value (non-interactive / testing).
89    #[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
99// ---------------------------------------------------------------------------
100// the trait impl — the entire ymusic-specific lifecycle surface
101// ---------------------------------------------------------------------------
102
103pub 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        // Delete the legacy `client-id` / `client-secret` slots so
153        // operators upgrading from the Data API era don't carry
154        // stale values around. The new TVHTML5 constants live in
155        // `zad::service::ymusic::oauth_device` and ship in the
156        // binary.
157        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
268// ---------------------------------------------------------------------------
269// device-flow helper
270// ---------------------------------------------------------------------------
271
272fn theme() -> ColorfulTheme {
273    ColorfulTheme::default()
274}
275
276/// Interactive device-flow OAuth. Surfaces the verification URL +
277/// user code to the operator and waits for the polling loop to
278/// resolve.
279async 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
334/// Captures a rotated refresh token into a shared cell. Used by
335/// `validate` so the refresh-token-on-rotation safety net survives
336/// the device-flow refactor.
337struct 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}