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        // The TVHTML5 device flow only grants the `youtube` scope; the
122        // resulting access token is rejected by the OpenID Connect
123        // userinfo endpoint (HTTP 401 "Invalid Credentials"). Identity
124        // therefore comes from InnerTube's `my_channel` browse, which
125        // works with the `youtube` scope we actually have.
126        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        // Delete the legacy `client-id` / `client-secret` slots so
151        // operators upgrading from the Data API era don't carry
152        // stale values around. The new TVHTML5 constants live in
153        // `zad::service::ymusic::oauth_device` and ship in the
154        // binary.
155        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
266// ---------------------------------------------------------------------------
267// device-flow helper
268// ---------------------------------------------------------------------------
269
270fn theme() -> ColorfulTheme {
271    ColorfulTheme::default()
272}
273
274/// Interactive device-flow OAuth. Surfaces the verification URL +
275/// user code to the operator and waits for the polling loop to
276/// resolve.
277async 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
332/// Captures a rotated refresh token into a shared cell. Used by
333/// `validate` so the refresh-token-on-rotation safety net survives
334/// the device-flow refactor.
335struct 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}