Skip to main content

zad_cli/cli/
ymusic.rs

1//! `zad ymusic <verb>` — runtime surface for the YouTube Music
2//! service.
3//!
4//! Wires together:
5//! - per-verb clap args (`search`, `playlists list/show/create/
6//!   rename/delete/add/remove`, `library {list,like,unlike}`, plus
7//!   the mandatory `permissions` subgroup);
8//! - credential + scope resolution from the effective config (local
9//!   wins over global);
10//! - permission gating (time window → target → content) executed
11//!   **before** any network call;
12//! - `--dry-run` for mutating verbs via the
13//!   [`zad::service::ymusic::YmusicTransport`] indirection so
14//!   previews never touch the network or the keychain.
15
16use std::collections::BTreeSet;
17use std::path::{Path, PathBuf};
18
19use clap::{Args, Subcommand};
20use serde::Serialize;
21
22use crate::cli::lifecycle::leak;
23use zad::config::{self, YmusicServiceCfg};
24use zad::error::{Result, ZadError};
25use zad::secrets::{self, Scope};
26use zad::service::default_dry_run_sink;
27use zad::service::ymusic::client::{
28    PlaylistItem, PlaylistSummary, Privacy, SearchItem, VideoSummary, YmusicHttp,
29};
30use zad::service::ymusic::permissions::{self as perms, YmusicFunction};
31use zad::service::ymusic::transport::{DryRunYmusicTransport, YmusicTransport};
32
33// ---------------------------------------------------------------------------
34// top-level args
35// ---------------------------------------------------------------------------
36
37#[derive(Debug, Args)]
38pub struct YmusicArgs {
39    #[command(subcommand)]
40    pub action: Action,
41}
42
43#[derive(Debug, Subcommand)]
44pub enum Action {
45    /// Search YouTube (videos, playlists, channels). YouTube Music
46    /// shares the Data API surface — songs are videos with the
47    /// `topicId` set to `Music`, but vanilla `video` queries cover
48    /// most cases.
49    Search(SearchArgs),
50    /// Playlist management (list, show, create, rename, delete, add, remove).
51    Playlists(PlaylistsArgs),
52    /// Library management — the user's liked videos.
53    Library(LibraryArgs),
54    /// Inspect or scaffold the permissions policy.
55    Permissions(PermissionsArgs),
56}
57
58// ---------------------------------------------------------------------------
59// `zad ymusic search …`
60// ---------------------------------------------------------------------------
61
62#[derive(Debug, Args)]
63pub struct SearchArgs {
64    /// Free-text query.
65    pub query: String,
66    /// One or more entity types (`video`, `playlist`, `channel`).
67    /// Repeatable. Defaults to `video`.
68    #[arg(long = "type", value_parser = ["video", "playlist", "channel"], default_values = ["video"])]
69    pub types: Vec<String>,
70    /// Page size (1..=50). YouTube caps every request at 50 items.
71    #[arg(long, default_value_t = 20)]
72    pub limit: u32,
73    #[arg(long)]
74    pub json: bool,
75}
76
77// ---------------------------------------------------------------------------
78// `zad ymusic playlists …`
79// ---------------------------------------------------------------------------
80
81#[derive(Debug, Args)]
82pub struct PlaylistsArgs {
83    #[command(subcommand)]
84    pub action: PlaylistsAction,
85}
86
87#[derive(Debug, Subcommand)]
88pub enum PlaylistsAction {
89    /// List the authenticated user's playlists.
90    List(PlaylistsListArgs),
91    /// Show one playlist's metadata and items.
92    Show(PlaylistsShowArgs),
93    /// Create a new playlist owned by the authenticated user.
94    Create(PlaylistsCreateArgs),
95    /// Rename an existing playlist.
96    Rename(PlaylistsRenameArgs),
97    /// Delete a playlist owned by the user.
98    Delete(PlaylistsDeleteArgs),
99    /// Add one or more videos to a playlist.
100    Add(PlaylistsAddArgs),
101    /// Remove one or more items from a playlist (by playlistItem ID
102    /// or by video ID — the latter is resolved by listing the
103    /// playlist first).
104    Remove(PlaylistsRemoveArgs),
105}
106
107#[derive(Debug, Args)]
108pub struct PlaylistsListArgs {
109    #[arg(long, default_value_t = 20)]
110    pub limit: u32,
111    #[arg(long)]
112    pub json: bool,
113}
114
115#[derive(Debug, Args)]
116pub struct PlaylistsShowArgs {
117    /// Playlist ID, full YouTube URL, or — when previously listed —
118    /// the literal title of an owned playlist.
119    pub playlist: Option<String>,
120    /// Page size for the items listing (1..=50).
121    #[arg(long, default_value_t = 50)]
122    pub limit: u32,
123    #[arg(long)]
124    pub json: bool,
125}
126
127#[derive(Debug, Args)]
128pub struct PlaylistsCreateArgs {
129    /// Display title for the new playlist.
130    pub title: String,
131    #[arg(long)]
132    pub description: Option<String>,
133    /// Privacy: `private` (default), `unlisted`, or `public`.
134    #[arg(long, value_parser = ["private", "unlisted", "public"], default_value = "private")]
135    pub privacy: String,
136    #[arg(long)]
137    pub dry_run: bool,
138    #[arg(long)]
139    pub json: bool,
140}
141
142#[derive(Debug, Args)]
143pub struct PlaylistsRenameArgs {
144    pub playlist: String,
145    pub new_title: String,
146    #[arg(long)]
147    pub dry_run: bool,
148    #[arg(long)]
149    pub json: bool,
150}
151
152#[derive(Debug, Args)]
153pub struct PlaylistsDeleteArgs {
154    pub playlist: String,
155    #[arg(long)]
156    pub dry_run: bool,
157    #[arg(long)]
158    pub json: bool,
159}
160
161#[derive(Debug, Args)]
162pub struct PlaylistsAddArgs {
163    /// Target playlist (ID, URL, or owned-playlist title).
164    pub playlist: String,
165    /// One or more video IDs (or full YouTube URLs).
166    #[arg(required = true)]
167    pub videos: Vec<String>,
168    #[arg(long)]
169    pub dry_run: bool,
170    #[arg(long)]
171    pub json: bool,
172}
173
174#[derive(Debug, Args)]
175pub struct PlaylistsRemoveArgs {
176    pub playlist: String,
177    /// One or more playlist-item IDs *or* video IDs. When a video ID
178    /// is supplied, zad lists the playlist to find the matching
179    /// item; if the same video appears multiple times, every match
180    /// is removed.
181    #[arg(required = true)]
182    pub items: Vec<String>,
183    #[arg(long)]
184    pub dry_run: bool,
185    #[arg(long)]
186    pub json: bool,
187}
188
189// ---------------------------------------------------------------------------
190// `zad ymusic library …`
191// ---------------------------------------------------------------------------
192
193#[derive(Debug, Args)]
194pub struct LibraryArgs {
195    #[command(subcommand)]
196    pub action: LibraryAction,
197}
198
199#[derive(Debug, Subcommand)]
200pub enum LibraryAction {
201    /// List the authenticated user's liked videos.
202    List(LibraryListArgs),
203    /// Like (save) one or more videos.
204    Like(LibraryMutateArgs),
205    /// Unlike (unsave) one or more videos.
206    Unlike(LibraryMutateArgs),
207}
208
209#[derive(Debug, Args)]
210pub struct LibraryListArgs {
211    #[arg(long, default_value_t = 20)]
212    pub limit: u32,
213    #[arg(long)]
214    pub json: bool,
215}
216
217#[derive(Debug, Args)]
218pub struct LibraryMutateArgs {
219    /// One or more video IDs (or full YouTube URLs).
220    #[arg(required = true)]
221    pub videos: Vec<String>,
222    #[arg(long)]
223    pub dry_run: bool,
224    #[arg(long)]
225    pub json: bool,
226}
227
228// ---------------------------------------------------------------------------
229// `zad ymusic permissions …`
230// ---------------------------------------------------------------------------
231
232#[derive(Debug, Args)]
233pub struct PermissionsArgs {
234    #[command(subcommand)]
235    pub action: Option<PermissionsAction>,
236    #[arg(long)]
237    pub json: bool,
238}
239
240#[derive(Debug, Subcommand)]
241#[allow(clippy::large_enum_variant)]
242pub enum PermissionsAction {
243    /// Print the effective policy (both file paths + bodies).
244    Show(PermissionsShowArgs),
245    /// Print the two candidate file paths, one per line.
246    Path(PermissionsPathArgs),
247    /// Write a starter policy to the selected scope.
248    Init(PermissionsInitArgs),
249    /// Dry-run a permissions check without hitting the network.
250    Check(PermissionsCheckArgs),
251    /// Staged-commit workflow: queue mutations in a `.pending` file
252    /// and only sign on `commit`. See `cli::permissions`.
253    #[command(flatten)]
254    Staging(crate::cli::permissions::StagingAction),
255}
256
257#[derive(Debug, Args)]
258pub struct PermissionsShowArgs {
259    #[arg(long)]
260    pub json: bool,
261}
262
263#[derive(Debug, Args)]
264pub struct PermissionsPathArgs {
265    #[arg(long)]
266    pub json: bool,
267}
268
269#[derive(Debug, Args)]
270pub struct PermissionsInitArgs {
271    #[arg(long)]
272    pub local: bool,
273    #[arg(long)]
274    pub force: bool,
275    #[arg(long)]
276    pub json: bool,
277}
278
279#[derive(Debug, Args)]
280pub struct PermissionsCheckArgs {
281    /// Function name: `search`, `playlists_read`, `playlists_write`,
282    /// `library_read`, or `library_write`.
283    #[arg(long)]
284    pub function: String,
285    /// Target to check against the function's `targets` list — a
286    /// playlist title/ID, a video ID, or a search query.
287    #[arg(long)]
288    pub target: Option<String>,
289    /// Body text to evaluate against the function's content rules
290    /// (e.g. a search query, a playlist description).
291    #[arg(long)]
292    pub body: Option<String>,
293    #[arg(long)]
294    pub json: bool,
295}
296
297// ---------------------------------------------------------------------------
298// dispatch
299// ---------------------------------------------------------------------------
300
301pub async fn run(args: YmusicArgs) -> Result<()> {
302    match args.action {
303        Action::Search(a) => run_search(a).await,
304        Action::Playlists(a) => match a.action {
305            PlaylistsAction::List(a) => run_playlists_list(a).await,
306            PlaylistsAction::Show(a) => run_playlists_show(a).await,
307            PlaylistsAction::Create(a) => run_playlists_create(a).await,
308            PlaylistsAction::Rename(a) => run_playlists_rename(a).await,
309            PlaylistsAction::Delete(a) => run_playlists_delete(a).await,
310            PlaylistsAction::Add(a) => run_playlists_add(a).await,
311            PlaylistsAction::Remove(a) => run_playlists_remove(a).await,
312        },
313        Action::Library(a) => match a.action {
314            LibraryAction::List(a) => run_library_list(a).await,
315            LibraryAction::Like(a) => run_library_mutate(a, true).await,
316            LibraryAction::Unlike(a) => run_library_mutate(a, false).await,
317        },
318        Action::Permissions(a) => run_permissions(a),
319    }
320}
321
322// ---------------------------------------------------------------------------
323// search
324// ---------------------------------------------------------------------------
325
326async fn run_search(args: SearchArgs) -> Result<()> {
327    let permissions = perms::load_effective()?;
328    permissions.check_time(YmusicFunction::Search)?;
329    permissions.check_target(YmusicFunction::Search, &args.query)?;
330    permissions.check_body(YmusicFunction::Search, &args.query)?;
331
332    let transport = transport_for(false)?;
333    let types: Vec<&str> = args.types.iter().map(|s| s.as_str()).collect();
334    let items = transport.search(&args.query, &types, args.limit).await?;
335
336    if args.json {
337        println!(
338            "{}",
339            serde_json::to_string_pretty(&render_search(&items)).unwrap()
340        );
341        return Ok(());
342    }
343    print_search_human(&items);
344    Ok(())
345}
346
347#[derive(Debug, Serialize)]
348struct SearchOutput {
349    items: Vec<SearchItemOut>,
350}
351
352#[derive(Debug, Serialize)]
353struct SearchItemOut {
354    kind: String,
355    id: String,
356    title: Option<String>,
357    channel_title: Option<String>,
358}
359
360fn render_search(items: &[SearchItem]) -> SearchOutput {
361    SearchOutput {
362        items: items
363            .iter()
364            .filter_map(|i| {
365                let id_block = i.id.as_ref()?;
366                let (kind, id) = if let Some(v) = id_block.video_id.as_ref() {
367                    ("video", v.clone())
368                } else if let Some(p) = id_block.playlist_id.as_ref() {
369                    ("playlist", p.clone())
370                } else if let Some(c) = id_block.channel_id.as_ref() {
371                    ("channel", c.clone())
372                } else {
373                    return None;
374                };
375                Some(SearchItemOut {
376                    kind: kind.to_string(),
377                    id,
378                    title: i.snippet.as_ref().and_then(|s| s.title.clone()),
379                    channel_title: i.snippet.as_ref().and_then(|s| s.channel_title.clone()),
380                })
381            })
382            .collect(),
383    }
384}
385
386fn print_search_human(items: &[SearchItem]) {
387    if items.is_empty() {
388        println!("No results.");
389        return;
390    }
391    for i in items {
392        let Some(id_block) = i.id.as_ref() else {
393            continue;
394        };
395        let (kind, id) = if let Some(v) = id_block.video_id.as_ref() {
396            ("video   ", v.as_str())
397        } else if let Some(p) = id_block.playlist_id.as_ref() {
398            ("playlist", p.as_str())
399        } else if let Some(c) = id_block.channel_id.as_ref() {
400            ("channel ", c.as_str())
401        } else {
402            continue;
403        };
404        let title = i
405            .snippet
406            .as_ref()
407            .and_then(|s| s.title.as_deref())
408            .unwrap_or("(no title)");
409        let channel = i
410            .snippet
411            .as_ref()
412            .and_then(|s| s.channel_title.as_deref())
413            .unwrap_or("?");
414        println!("  {kind} {id:24}  {title} [{channel}]");
415    }
416}
417
418// ---------------------------------------------------------------------------
419// playlists
420// ---------------------------------------------------------------------------
421
422async fn run_playlists_list(args: PlaylistsListArgs) -> Result<()> {
423    let permissions = perms::load_effective()?;
424    permissions.check_time(YmusicFunction::PlaylistsRead)?;
425
426    let transport = transport_for(false)?;
427    let items = transport.list_my_playlists(Some(args.limit)).await?;
428    let filtered: Vec<&PlaylistSummary> = items
429        .iter()
430        .filter(|p| {
431            let title = p.snippet.as_ref().map(|s| s.title.as_str()).unwrap_or("");
432            permissions
433                .check_target(YmusicFunction::PlaylistsRead, title)
434                .is_ok()
435        })
436        .collect();
437
438    if args.json {
439        println!("{}", serde_json::to_string_pretty(&filtered).unwrap());
440        return Ok(());
441    }
442    if filtered.is_empty() {
443        println!("No playlists visible (or all filtered by permissions).");
444        return Ok(());
445    }
446    for p in &filtered {
447        let title = p
448            .snippet
449            .as_ref()
450            .map(|s| s.title.as_str())
451            .unwrap_or("(no title)");
452        let total = p
453            .content_details
454            .as_ref()
455            .and_then(|c| c.item_count)
456            .unwrap_or(0);
457        let privacy = p
458            .status
459            .as_ref()
460            .and_then(|s| s.privacy_status.as_deref())
461            .unwrap_or("?");
462        println!("  {:36}  {title} ({total} items, {privacy})", p.id);
463    }
464    Ok(())
465}
466
467async fn run_playlists_show(args: PlaylistsShowArgs) -> Result<()> {
468    let permissions = perms::load_effective()?;
469    permissions.check_time(YmusicFunction::PlaylistsRead)?;
470
471    let (cfg, _label, _scope, _path) = effective_config()?;
472    let raw = playlist_target(args.playlist.as_deref(), cfg.default_playlist.as_deref())?;
473    permissions.check_target(YmusicFunction::PlaylistsRead, &raw)?;
474    let resolved = strip_playlist_url(&raw);
475
476    let transport = transport_for(false)?;
477    let summary = transport.get_playlist(&resolved).await?;
478    let items = transport
479        .get_playlist_items(&resolved, Some(args.limit))
480        .await?;
481
482    if args.json {
483        let out = serde_json::json!({ "playlist": summary, "items": items });
484        println!("{}", serde_json::to_string_pretty(&out).unwrap());
485        return Ok(());
486    }
487    println!("id          : {}", summary.id);
488    if let Some(s) = &summary.snippet {
489        println!("title       : {}", s.title);
490        if let Some(d) = &s.description {
491            if !d.is_empty() {
492                println!("description : {d}");
493            }
494        }
495    }
496    if let Some(s) = &summary.status {
497        if let Some(p) = &s.privacy_status {
498            println!("privacy     : {p}");
499        }
500    }
501    println!("items       :");
502    for item in &items {
503        print_playlist_item(item);
504    }
505    Ok(())
506}
507
508fn print_playlist_item(item: &PlaylistItem) {
509    let video_id = item
510        .content_details
511        .as_ref()
512        .and_then(|c| c.video_id.as_deref())
513        .or_else(|| {
514            item.snippet
515                .as_ref()
516                .and_then(|s| s.resource_id.as_ref())
517                .and_then(|r| r.video_id.as_deref())
518        })
519        .unwrap_or("?");
520    let title = item
521        .snippet
522        .as_ref()
523        .and_then(|s| s.title.as_deref())
524        .unwrap_or("(no title)");
525    let owner = item
526        .snippet
527        .as_ref()
528        .and_then(|s| s.video_owner_channel_title.as_deref())
529        .unwrap_or("?");
530    println!("  item={} video={video_id}  {title} [{owner}]", item.id);
531}
532
533async fn run_playlists_create(args: PlaylistsCreateArgs) -> Result<()> {
534    let permissions = perms::load_effective()?;
535    permissions.check_time(YmusicFunction::PlaylistsWrite)?;
536    permissions.check_target(YmusicFunction::PlaylistsWrite, &args.title)?;
537    permissions.check_body(YmusicFunction::PlaylistsWrite, &args.title)?;
538    if let Some(d) = &args.description {
539        permissions.check_body(YmusicFunction::PlaylistsWrite, d)?;
540    }
541
542    let privacy = parse_privacy(&args.privacy)?;
543    let transport = transport_for(args.dry_run)?;
544    let summary = transport
545        .create_playlist(&args.title, args.description.as_deref(), privacy)
546        .await?;
547
548    if args.dry_run {
549        return Ok(());
550    }
551    if args.json {
552        println!("{}", serde_json::to_string_pretty(&summary).unwrap());
553    } else {
554        let title = summary
555            .snippet
556            .as_ref()
557            .map(|s| s.title.as_str())
558            .unwrap_or(args.title.as_str());
559        println!("Created playlist `{title}` (id={})", summary.id);
560    }
561    Ok(())
562}
563
564async fn run_playlists_rename(args: PlaylistsRenameArgs) -> Result<()> {
565    let permissions = perms::load_effective()?;
566    permissions.check_time(YmusicFunction::PlaylistsWrite)?;
567    permissions.check_target(YmusicFunction::PlaylistsWrite, &args.playlist)?;
568    permissions.check_target(YmusicFunction::PlaylistsWrite, &args.new_title)?;
569    permissions.check_body(YmusicFunction::PlaylistsWrite, &args.new_title)?;
570
571    let resolved = strip_playlist_url(&args.playlist);
572    let transport = transport_for(args.dry_run)?;
573    transport
574        .rename_playlist(&resolved, &args.new_title)
575        .await?;
576
577    if args.dry_run {
578        return Ok(());
579    }
580    if args.json {
581        let out = serde_json::json!({ "id": resolved, "new_title": args.new_title });
582        println!("{}", serde_json::to_string_pretty(&out).unwrap());
583    } else {
584        println!("Renamed `{resolved}` → `{}`", args.new_title);
585    }
586    Ok(())
587}
588
589async fn run_playlists_delete(args: PlaylistsDeleteArgs) -> Result<()> {
590    let permissions = perms::load_effective()?;
591    permissions.check_time(YmusicFunction::PlaylistsWrite)?;
592    permissions.check_target(YmusicFunction::PlaylistsWrite, &args.playlist)?;
593
594    let resolved = strip_playlist_url(&args.playlist);
595    let transport = transport_for(args.dry_run)?;
596    transport.delete_playlist(&resolved).await?;
597
598    if args.dry_run {
599        return Ok(());
600    }
601    if args.json {
602        let out = serde_json::json!({ "id": resolved, "deleted": true });
603        println!("{}", serde_json::to_string_pretty(&out).unwrap());
604    } else {
605        println!("Deleted playlist `{resolved}`");
606    }
607    Ok(())
608}
609
610async fn run_playlists_add(args: PlaylistsAddArgs) -> Result<()> {
611    let permissions = perms::load_effective()?;
612    permissions.check_time(YmusicFunction::PlaylistsWrite)?;
613    permissions.check_target(YmusicFunction::PlaylistsWrite, &args.playlist)?;
614    for v in &args.videos {
615        permissions.check_target(YmusicFunction::PlaylistsWrite, v)?;
616    }
617
618    let resolved = strip_playlist_url(&args.playlist);
619    let video_ids: Vec<String> = args.videos.iter().map(|v| extract_video_id(v)).collect();
620    let transport = transport_for(args.dry_run)?;
621    let mut added: Vec<String> = Vec::with_capacity(video_ids.len());
622    for vid in &video_ids {
623        let item_id = transport.add_playlist_item(&resolved, vid).await?;
624        added.push(item_id);
625    }
626
627    if args.dry_run {
628        return Ok(());
629    }
630    if args.json {
631        let out = serde_json::json!({ "id": resolved, "added": added });
632        println!("{}", serde_json::to_string_pretty(&out).unwrap());
633    } else {
634        println!("Added {} video(s) to `{resolved}`", added.len());
635    }
636    Ok(())
637}
638
639async fn run_playlists_remove(args: PlaylistsRemoveArgs) -> Result<()> {
640    let permissions = perms::load_effective()?;
641    permissions.check_time(YmusicFunction::PlaylistsWrite)?;
642    permissions.check_target(YmusicFunction::PlaylistsWrite, &args.playlist)?;
643    for v in &args.items {
644        permissions.check_target(YmusicFunction::PlaylistsWrite, v)?;
645    }
646
647    let resolved = strip_playlist_url(&args.playlist);
648    let transport = transport_for(args.dry_run)?;
649
650    // Resolve video IDs to playlistItem IDs by listing the playlist
651    // once. Anything that already looks like a playlistItem ID
652    // (length > 24, starts with `PL` or `UE` is not the rule —
653    // YouTube uses opaque IDs) is just attempted as-is and we let
654    // the API surface a 404 if it's not a real item.
655    let listing: Option<Vec<PlaylistItem>> = if args.items.iter().any(|s| is_likely_video_id(s)) {
656        Some(transport.get_playlist_items(&resolved, Some(50)).await?)
657    } else {
658        None
659    };
660
661    let mut removed: Vec<String> = Vec::new();
662    for raw in &args.items {
663        let candidate = extract_video_id(raw);
664        if is_likely_video_id(raw) {
665            // Map a video ID to every matching playlistItem ID.
666            if let Some(list) = listing.as_ref() {
667                let matches: Vec<&PlaylistItem> = list
668                    .iter()
669                    .filter(|it| {
670                        it.content_details
671                            .as_ref()
672                            .and_then(|c| c.video_id.as_deref())
673                            == Some(candidate.as_str())
674                            || it
675                                .snippet
676                                .as_ref()
677                                .and_then(|s| s.resource_id.as_ref())
678                                .and_then(|r| r.video_id.as_deref())
679                                == Some(candidate.as_str())
680                    })
681                    .collect();
682                if matches.is_empty() {
683                    return Err(ZadError::Service {
684                        name: "ymusic",
685                        message: format!("video `{candidate}` is not in playlist `{resolved}`"),
686                    });
687                }
688                for m in matches {
689                    transport.remove_playlist_item(&m.id).await?;
690                    removed.push(m.id.clone());
691                }
692            }
693        } else {
694            // Treat raw as an opaque playlistItem ID.
695            transport.remove_playlist_item(raw).await?;
696            removed.push(raw.clone());
697        }
698    }
699
700    if args.dry_run {
701        return Ok(());
702    }
703    if args.json {
704        let out = serde_json::json!({ "id": resolved, "removed": removed });
705        println!("{}", serde_json::to_string_pretty(&out).unwrap());
706    } else {
707        println!("Removed {} item(s) from `{resolved}`", removed.len());
708    }
709    Ok(())
710}
711
712// ---------------------------------------------------------------------------
713// library
714// ---------------------------------------------------------------------------
715
716async fn run_library_list(args: LibraryListArgs) -> Result<()> {
717    let permissions = perms::load_effective()?;
718    permissions.check_time(YmusicFunction::LibraryRead)?;
719
720    let transport = transport_for(false)?;
721    let items = transport.list_liked_videos(Some(args.limit)).await?;
722    let filtered: Vec<&VideoSummary> = items
723        .iter()
724        .filter(|v| {
725            permissions
726                .check_target(YmusicFunction::LibraryRead, &v.id)
727                .is_ok()
728        })
729        .collect();
730
731    if args.json {
732        println!("{}", serde_json::to_string_pretty(&filtered).unwrap());
733        return Ok(());
734    }
735    if filtered.is_empty() {
736        println!("No liked videos (or all filtered by permissions).");
737        return Ok(());
738    }
739    for v in &filtered {
740        let title = v
741            .snippet
742            .as_ref()
743            .map(|s| s.title.as_str())
744            .unwrap_or("(no title)");
745        let channel = v
746            .snippet
747            .as_ref()
748            .and_then(|s| s.channel_title.as_deref())
749            .unwrap_or("?");
750        println!("  {:24}  {title} [{channel}]", v.id);
751    }
752    Ok(())
753}
754
755async fn run_library_mutate(args: LibraryMutateArgs, like: bool) -> Result<()> {
756    let permissions = perms::load_effective()?;
757    permissions.check_time(YmusicFunction::LibraryWrite)?;
758    for v in &args.videos {
759        permissions.check_target(YmusicFunction::LibraryWrite, v)?;
760    }
761
762    let ids: Vec<String> = args.videos.iter().map(|v| extract_video_id(v)).collect();
763    let transport = transport_for(args.dry_run)?;
764    for id in &ids {
765        if like {
766            transport.like_video(id).await?;
767        } else {
768            transport.unlike_video(id).await?;
769        }
770    }
771
772    if args.dry_run {
773        return Ok(());
774    }
775    let verb = if like { "liked" } else { "unliked" };
776    if args.json {
777        let out = serde_json::json!({ verb: ids });
778        println!("{}", serde_json::to_string_pretty(&out).unwrap());
779    } else {
780        println!("{verb} {} video(s)", ids.len());
781    }
782    Ok(())
783}
784
785// ---------------------------------------------------------------------------
786// permissions subgroup — show / path / init / check
787// ---------------------------------------------------------------------------
788
789fn run_permissions(args: PermissionsArgs) -> Result<()> {
790    match args.action {
791        None => run_permissions_show(PermissionsShowArgs { json: args.json }),
792        Some(PermissionsAction::Show(a)) => run_permissions_show(a),
793        Some(PermissionsAction::Path(a)) => run_permissions_path(a),
794        Some(PermissionsAction::Init(a)) => run_permissions_init(a),
795        Some(PermissionsAction::Check(a)) => run_permissions_check(a),
796        Some(PermissionsAction::Staging(a)) => {
797            crate::cli::permissions::run::<perms::PermissionsService>(a)
798        }
799    }
800}
801
802#[derive(Debug, Serialize)]
803struct PermissionsScopeOut {
804    path: String,
805    present: bool,
806}
807
808#[derive(Debug, Serialize)]
809struct PermissionsShowOut {
810    command: &'static str,
811    global: PermissionsScopeOut,
812    local: PermissionsScopeOut,
813}
814
815fn run_permissions_show(args: PermissionsShowArgs) -> Result<()> {
816    let global_path = perms::global_path()?;
817    let local_path = perms::local_path_current()?;
818    let _ = perms::load_effective()?;
819
820    if args.json {
821        let out = PermissionsShowOut {
822            command: "ymusic.permissions.show",
823            global: PermissionsScopeOut {
824                path: global_path.display().to_string(),
825                present: global_path.exists(),
826            },
827            local: PermissionsScopeOut {
828                path: local_path.display().to_string(),
829                present: local_path.exists(),
830            },
831        };
832        println!("{}", serde_json::to_string_pretty(&out).unwrap());
833        return Ok(());
834    }
835    println!("YouTube Music permissions");
836    print_scope_block("global", &global_path);
837    print_scope_block("local", &local_path);
838    Ok(())
839}
840
841fn print_scope_block(label: &str, path: &Path) {
842    println!();
843    println!("  [{label}] {}", path.display());
844    if !path.exists() {
845        println!("    (no file at this scope)");
846        return;
847    }
848    match std::fs::read_to_string(path) {
849        Ok(body) => {
850            for line in body.lines() {
851                println!("    {line}");
852            }
853        }
854        Err(e) => println!("    (could not read: {e})"),
855    }
856}
857
858fn run_permissions_path(args: PermissionsPathArgs) -> Result<()> {
859    let global_path = perms::global_path()?;
860    let local_path = perms::local_path_current()?;
861    if args.json {
862        let out = serde_json::json!({
863            "command": "ymusic.permissions.path",
864            "global": global_path.display().to_string(),
865            "local": local_path.display().to_string(),
866        });
867        println!("{}", serde_json::to_string_pretty(&out).unwrap());
868    } else {
869        println!("{}", global_path.display());
870        println!("{}", local_path.display());
871    }
872    Ok(())
873}
874
875#[derive(Debug, Serialize)]
876struct PermissionsInitOutput {
877    command: &'static str,
878    scope: &'static str,
879    path: String,
880    written: bool,
881}
882
883fn run_permissions_init(args: PermissionsInitArgs) -> Result<()> {
884    let (path, scope) = if args.local {
885        (perms::local_path_current()?, "local")
886    } else {
887        (perms::global_path()?, "global")
888    };
889    if path.exists() && !args.force {
890        return Err(ZadError::Invalid(format!(
891            "permissions file already exists at {}. Pass --force to overwrite.",
892            path.display()
893        )));
894    }
895    let template = perms::starter_template();
896    let key = zad::permissions::signing::load_or_create_from_keychain()?;
897    zad::permissions::signing::write_public_key_cache(&key)?;
898    perms::save_file(&path, &template, &key)?;
899    if args.json {
900        let out = PermissionsInitOutput {
901            command: "ymusic.permissions.init",
902            scope,
903            path: path.display().to_string(),
904            written: true,
905        };
906        println!("{}", serde_json::to_string_pretty(&out).unwrap());
907    } else {
908        println!("Wrote starter permissions ({scope}): {}", path.display());
909        println!("Signed with key {}.", key.fingerprint());
910        println!("Review it; the defaults deny `*release*`/`*official*` playlists.");
911    }
912    Ok(())
913}
914
915fn run_permissions_check(args: PermissionsCheckArgs) -> Result<()> {
916    let f = parse_function(&args.function)?;
917    let permissions = perms::load_effective()?;
918
919    permissions.check_time(f)?;
920    if let Some(t) = &args.target {
921        permissions.check_target(f, t)?;
922    }
923    if let Some(b) = &args.body {
924        permissions.check_body(f, b)?;
925    }
926
927    if args.json {
928        let out = serde_json::json!({
929            "command": "ymusic.permissions.check",
930            "function": args.function,
931            "ok": true,
932        });
933        println!("{}", serde_json::to_string_pretty(&out).unwrap());
934    } else {
935        println!("✓ would be allowed by current ymusic permissions");
936    }
937    Ok(())
938}
939
940fn parse_function(name: &str) -> Result<YmusicFunction> {
941    Ok(match name {
942        "search" => YmusicFunction::Search,
943        "playlists_read" => YmusicFunction::PlaylistsRead,
944        "playlists_write" => YmusicFunction::PlaylistsWrite,
945        "library_read" => YmusicFunction::LibraryRead,
946        "library_write" => YmusicFunction::LibraryWrite,
947        other => {
948            return Err(ZadError::Invalid(format!(
949                "unknown function `{other}`; expected one of: search, playlists_read, \
950                 playlists_write, library_read, library_write"
951            )));
952        }
953    })
954}
955
956// ---------------------------------------------------------------------------
957// shared helpers
958// ---------------------------------------------------------------------------
959
960pub(crate) fn effective_config() -> Result<(YmusicServiceCfg, &'static str, Scope<'static>, PathBuf)>
961{
962    let slug = config::path::project_slug()?;
963    let local_path = config::path::project_service_config_path_for(&slug, "ymusic")?;
964    let global_path = config::path::global_service_config_path("ymusic")?;
965
966    let project_cfg = config::load()?;
967    if !project_cfg.has_service("ymusic") {
968        return Err(ZadError::Invalid(format!(
969            "ymusic is not enabled for this project ({}). \
970             Run `zad service enable ymusic` first.",
971            config::path::project_config_path()?.display()
972        )));
973    }
974
975    if let Some(cfg) = config::load_flat::<YmusicServiceCfg>(&local_path)? {
976        let slug_leaked = leak(slug);
977        return Ok((cfg, "local", Scope::Project(slug_leaked), local_path));
978    }
979    if let Some(cfg) = config::load_flat::<YmusicServiceCfg>(&global_path)? {
980        return Ok((cfg, "global", Scope::Global, global_path));
981    }
982    Err(ZadError::Invalid(format!(
983        "no ymusic credentials found.\n  looked in:\n    {}\n    {}\n  \
984         Run `zad service create ymusic`.",
985        local_path.display(),
986        global_path.display()
987    )))
988}
989
990/// Live `YmusicHttp` for the effective scope. The refresh token is
991/// the only per-user secret in the InnerTube era; the OAuth client
992/// identity comes from the TVHTML5 constants compiled into the
993/// binary.
994fn http_for() -> Result<YmusicHttp> {
995    let (cfg, _label, scope, path) = effective_config()?;
996    let refresh_token =
997        secrets::load(&secrets::account("ymusic", "refresh", scope))?.ok_or(ZadError::Service {
998            name: "ymusic",
999            message: "refresh token missing from keychain; re-run `zad service create ymusic`"
1000                .into(),
1001        })?;
1002    let scope_set: BTreeSet<String> = cfg.scopes.iter().cloned().collect();
1003    Ok(YmusicHttp::new(
1004        String::new(),
1005        String::new(),
1006        refresh_token,
1007        scope_set,
1008        path,
1009    ))
1010}
1011
1012/// Build a transport: `--dry-run` returns the preview impl,
1013/// otherwise the live HTTP client.
1014fn transport_for(dry_run: bool) -> Result<Box<dyn YmusicTransport>> {
1015    if dry_run {
1016        Ok(Box::new(DryRunYmusicTransport::new(default_dry_run_sink())))
1017    } else {
1018        Ok(Box::new(http_for()?))
1019    }
1020}
1021
1022/// Resolve `--playlist <raw>` against `default_playlist` fallback.
1023fn playlist_target(flag: Option<&str>, default: Option<&str>) -> Result<String> {
1024    if let Some(v) = flag {
1025        return Ok(v.to_string());
1026    }
1027    if let Some(v) = default {
1028        return Ok(v.to_string());
1029    }
1030    Err(ZadError::MissingRequired(
1031        "--playlist (or set `default_playlist` in the ymusic config)",
1032    ))
1033}
1034
1035/// Strip a YouTube playlist URL down to its raw `PL…` ID. Bare IDs
1036/// pass through. Accepts the common `https://music.youtube.com/
1037/// playlist?list=PL…` and `https://www.youtube.com/playlist?list=PL…`
1038/// forms.
1039fn strip_playlist_url(s: &str) -> String {
1040    if let Some(idx) = s.find("list=") {
1041        let rest = &s[idx + 5..];
1042        let end = rest.find(['&', '#']).unwrap_or(rest.len());
1043        return rest[..end].to_string();
1044    }
1045    s.to_string()
1046}
1047
1048/// Pull a video ID out of a YouTube URL or pass a bare ID through.
1049/// Recognised forms: `youtube.com/watch?v=…`, `music.youtube.com/
1050/// watch?v=…`, `youtu.be/…`. Anything else is returned as-is.
1051fn extract_video_id(s: &str) -> String {
1052    if let Some(idx) = s.find("v=") {
1053        let rest = &s[idx + 2..];
1054        let end = rest.find(['&', '#']).unwrap_or(rest.len());
1055        return rest[..end].to_string();
1056    }
1057    if let Some(rest) = s.strip_prefix("https://youtu.be/") {
1058        let end = rest.find(['?', '&', '#', '/']).unwrap_or(rest.len());
1059        return rest[..end].to_string();
1060    }
1061    s.to_string()
1062}
1063
1064/// Heuristic for "does this look like a YouTube video ID rather than
1065/// a playlistItem ID?". Video IDs are exactly 11 chars from the
1066/// `[A-Za-z0-9_-]` alphabet; playlistItem IDs are much longer
1067/// base64-ish strings.
1068fn is_likely_video_id(s: &str) -> bool {
1069    let id = extract_video_id(s);
1070    id.len() == 11
1071        && id
1072            .chars()
1073            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1074}
1075
1076fn parse_privacy(s: &str) -> Result<Privacy> {
1077    Ok(match s {
1078        "private" => Privacy::Private,
1079        "unlisted" => Privacy::Unlisted,
1080        "public" => Privacy::Public,
1081        other => {
1082            return Err(ZadError::Invalid(format!(
1083                "unknown privacy `{other}`; expected one of: private, unlisted, public"
1084            )));
1085        }
1086    })
1087}