1use 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#[derive(Debug, Args)]
38pub struct YmusicArgs {
39 #[command(subcommand)]
40 pub action: Action,
41}
42
43#[derive(Debug, Subcommand)]
44pub enum Action {
45 Search(SearchArgs),
50 Playlists(PlaylistsArgs),
52 Library(LibraryArgs),
54 Permissions(PermissionsArgs),
56}
57
58#[derive(Debug, Args)]
63pub struct SearchArgs {
64 pub query: String,
66 #[arg(long = "type", value_parser = ["video", "playlist", "channel"], default_values = ["video"])]
69 pub types: Vec<String>,
70 #[arg(long, default_value_t = 20)]
72 pub limit: u32,
73 #[arg(long)]
74 pub json: bool,
75}
76
77#[derive(Debug, Args)]
82pub struct PlaylistsArgs {
83 #[command(subcommand)]
84 pub action: PlaylistsAction,
85}
86
87#[derive(Debug, Subcommand)]
88pub enum PlaylistsAction {
89 List(PlaylistsListArgs),
91 Show(PlaylistsShowArgs),
93 Create(PlaylistsCreateArgs),
95 Rename(PlaylistsRenameArgs),
97 Delete(PlaylistsDeleteArgs),
99 Add(PlaylistsAddArgs),
101 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 pub playlist: Option<String>,
120 #[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 pub title: String,
131 #[arg(long)]
132 pub description: Option<String>,
133 #[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 pub playlist: String,
165 #[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 #[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#[derive(Debug, Args)]
194pub struct LibraryArgs {
195 #[command(subcommand)]
196 pub action: LibraryAction,
197}
198
199#[derive(Debug, Subcommand)]
200pub enum LibraryAction {
201 List(LibraryListArgs),
203 Like(LibraryMutateArgs),
205 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 #[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#[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 Show(PermissionsShowArgs),
245 Path(PermissionsPathArgs),
247 Init(PermissionsInitArgs),
249 Check(PermissionsCheckArgs),
251 #[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 #[arg(long)]
284 pub function: String,
285 #[arg(long)]
288 pub target: Option<String>,
289 #[arg(long)]
292 pub body: Option<String>,
293 #[arg(long)]
294 pub json: bool,
295}
296
297pub 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
322async 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
418async 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 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 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 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
712async 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
785fn 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
956pub(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
990fn 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
1012fn 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
1022fn 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
1035fn 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
1048fn 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
1064fn 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}