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, SpotifyServiceCfg};
24use zad::error::{Result, ZadError};
25use zad::secrets::{self, Scope};
26use zad::service::spotify::client::{
27 PlaylistSummary, PlaylistTrackItem, SavedAlbum, SavedTrack, SearchResults, SpotifyHttp,
28};
29use zad::service::spotify::permissions::{self as perms, SpotifyFunction};
30
31#[derive(Debug, Args)]
36pub struct SpotifyArgs {
37 #[command(subcommand)]
38 pub action: Action,
39}
40
41#[derive(Debug, Subcommand)]
42pub enum Action {
43 Search(SearchArgs),
45 Playlists(PlaylistsArgs),
47 Library(LibraryArgs),
49 Permissions(PermissionsArgs),
51}
52
53#[derive(Debug, Args)]
58pub struct SearchArgs {
59 pub query: String,
61 #[arg(long = "type", value_parser = ["track", "album", "artist", "playlist"], default_values = ["track"])]
64 pub types: Vec<String>,
65 #[arg(long, default_value_t = 20)]
67 pub limit: u32,
68 #[arg(long)]
69 pub json: bool,
70}
71
72#[derive(Debug, Args)]
77pub struct PlaylistsArgs {
78 #[command(subcommand)]
79 pub action: PlaylistsAction,
80}
81
82#[derive(Debug, Subcommand)]
83pub enum PlaylistsAction {
84 List(PlaylistsListArgs),
86 Show(PlaylistsShowArgs),
88 Create(PlaylistsCreateArgs),
90 Rename(PlaylistsRenameArgs),
92 Delete(PlaylistsDeleteArgs),
94 Add(PlaylistsAddArgs),
96 Remove(PlaylistsRemoveArgs),
98}
99
100#[derive(Debug, Args)]
101pub struct PlaylistsListArgs {
102 #[arg(long, default_value_t = 20)]
103 pub limit: u32,
104 #[arg(long)]
105 pub json: bool,
106}
107
108#[derive(Debug, Args)]
109pub struct PlaylistsShowArgs {
110 pub playlist: Option<String>,
113 #[arg(long, default_value_t = 50)]
115 pub limit: u32,
116 #[arg(long)]
117 pub json: bool,
118}
119
120#[derive(Debug, Args)]
121pub struct PlaylistsCreateArgs {
122 pub name: String,
124 #[arg(long)]
125 pub description: Option<String>,
126 #[arg(long)]
128 pub public: bool,
129 #[arg(long)]
130 pub json: bool,
131}
132
133#[derive(Debug, Args)]
134pub struct PlaylistsRenameArgs {
135 pub playlist: String,
136 pub new_name: String,
137 #[arg(long)]
138 pub json: bool,
139}
140
141#[derive(Debug, Args)]
142pub struct PlaylistsDeleteArgs {
143 pub playlist: String,
144 #[arg(long)]
145 pub json: bool,
146}
147
148#[derive(Debug, Args)]
149pub struct PlaylistsAddArgs {
150 pub playlist: String,
152 #[arg(required = true)]
154 pub tracks: Vec<String>,
155 #[arg(long)]
156 pub json: bool,
157}
158
159#[derive(Debug, Args)]
160pub struct PlaylistsRemoveArgs {
161 pub playlist: String,
162 #[arg(required = true)]
163 pub tracks: Vec<String>,
164 #[arg(long)]
165 pub json: bool,
166}
167
168#[derive(Debug, Args)]
173pub struct LibraryArgs {
174 #[command(subcommand)]
175 pub action: LibraryAction,
176}
177
178#[derive(Debug, Subcommand)]
179pub enum LibraryAction {
180 Tracks(LibraryTracksArgs),
182 Albums(LibraryAlbumsArgs),
184}
185
186#[derive(Debug, Args)]
187pub struct LibraryTracksArgs {
188 #[command(subcommand)]
189 pub action: LibraryItemAction,
190}
191
192#[derive(Debug, Args)]
193pub struct LibraryAlbumsArgs {
194 #[command(subcommand)]
195 pub action: LibraryItemAction,
196}
197
198#[derive(Debug, Subcommand)]
199pub enum LibraryItemAction {
200 List(LibraryListArgs),
202 Save(LibraryMutateArgs),
204 Unsave(LibraryMutateArgs),
206}
207
208#[derive(Debug, Args)]
209pub struct LibraryListArgs {
210 #[arg(long, default_value_t = 20)]
211 pub limit: u32,
212 #[arg(long)]
213 pub json: bool,
214}
215
216#[derive(Debug, Args)]
217pub struct LibraryMutateArgs {
218 #[arg(required = true)]
221 pub items: Vec<String>,
222 #[arg(long)]
223 pub json: bool,
224}
225
226#[derive(Debug, Args)]
231pub struct PermissionsArgs {
232 #[command(subcommand)]
233 pub action: Option<PermissionsAction>,
234 #[arg(long)]
235 pub json: bool,
236}
237
238#[derive(Debug, Subcommand)]
239#[allow(clippy::large_enum_variant)]
240pub enum PermissionsAction {
241 Show(PermissionsShowArgs),
243 Path(PermissionsPathArgs),
245 Init(PermissionsInitArgs),
247 Check(PermissionsCheckArgs),
249 #[command(flatten)]
252 Staging(crate::cli::permissions::StagingAction),
253}
254
255#[derive(Debug, Args)]
256pub struct PermissionsShowArgs {
257 #[arg(long)]
258 pub json: bool,
259}
260
261#[derive(Debug, Args)]
262pub struct PermissionsPathArgs {
263 #[arg(long)]
264 pub json: bool,
265}
266
267#[derive(Debug, Args)]
268pub struct PermissionsInitArgs {
269 #[arg(long)]
270 pub local: bool,
271 #[arg(long)]
272 pub force: bool,
273 #[arg(long)]
274 pub json: bool,
275}
276
277#[derive(Debug, Args)]
278pub struct PermissionsCheckArgs {
279 #[arg(long)]
282 pub function: String,
283 #[arg(long)]
286 pub target: Option<String>,
287 #[arg(long)]
290 pub body: Option<String>,
291 #[arg(long)]
292 pub json: bool,
293}
294
295pub async fn run(args: SpotifyArgs) -> Result<()> {
300 match args.action {
301 Action::Search(a) => run_search(a).await,
302 Action::Playlists(a) => match a.action {
303 PlaylistsAction::List(a) => run_playlists_list(a).await,
304 PlaylistsAction::Show(a) => run_playlists_show(a).await,
305 PlaylistsAction::Create(a) => run_playlists_create(a).await,
306 PlaylistsAction::Rename(a) => run_playlists_rename(a).await,
307 PlaylistsAction::Delete(a) => run_playlists_delete(a).await,
308 PlaylistsAction::Add(a) => run_playlists_add(a).await,
309 PlaylistsAction::Remove(a) => run_playlists_remove(a).await,
310 },
311 Action::Library(a) => match a.action {
312 LibraryAction::Tracks(t) => match t.action {
313 LibraryItemAction::List(a) => run_library_tracks_list(a).await,
314 LibraryItemAction::Save(a) => run_library_tracks_save(a).await,
315 LibraryItemAction::Unsave(a) => run_library_tracks_unsave(a).await,
316 },
317 LibraryAction::Albums(t) => match t.action {
318 LibraryItemAction::List(a) => run_library_albums_list(a).await,
319 LibraryItemAction::Save(a) => run_library_albums_save(a).await,
320 LibraryItemAction::Unsave(a) => run_library_albums_unsave(a).await,
321 },
322 },
323 Action::Permissions(a) => run_permissions(a),
324 }
325}
326
327async fn run_search(args: SearchArgs) -> Result<()> {
332 let permissions = perms::load_effective()?;
333 permissions.check_time(SpotifyFunction::Search)?;
334 permissions.check_target(SpotifyFunction::Search, &args.query)?;
335 permissions.check_body(SpotifyFunction::Search, &args.query)?;
336
337 let http = http_for()?;
338 let types: Vec<&str> = args.types.iter().map(|s| s.as_str()).collect();
339 let results = http.search(&args.query, &types, args.limit).await?;
340
341 if args.json {
342 println!(
343 "{}",
344 serde_json::to_string_pretty(&render_search(&results)).unwrap()
345 );
346 return Ok(());
347 }
348 print_search_human(&results);
349 Ok(())
350}
351
352#[derive(Debug, Serialize)]
353struct SearchOutput {
354 #[serde(skip_serializing_if = "Option::is_none")]
355 tracks: Option<Vec<TrackOut>>,
356 #[serde(skip_serializing_if = "Option::is_none")]
357 albums: Option<Vec<AlbumOut>>,
358 #[serde(skip_serializing_if = "Option::is_none")]
359 artists: Option<Vec<ArtistOut>>,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 playlists: Option<Vec<PlaylistOut>>,
362}
363
364#[derive(Debug, Serialize)]
365struct TrackOut {
366 id: String,
367 name: String,
368 uri: Option<String>,
369 artists: Vec<String>,
370 album: Option<String>,
371 explicit: Option<bool>,
372}
373
374#[derive(Debug, Serialize)]
375struct AlbumOut {
376 id: String,
377 name: String,
378 uri: Option<String>,
379 release_date: Option<String>,
380 artists: Vec<String>,
381 total_tracks: Option<u32>,
382}
383
384#[derive(Debug, Serialize)]
385struct ArtistOut {
386 id: String,
387 name: String,
388 uri: Option<String>,
389 genres: Vec<String>,
390}
391
392#[derive(Debug, Serialize)]
393struct PlaylistOut {
394 id: String,
395 name: String,
396 uri: Option<String>,
397 owner: Option<String>,
398 public: Option<bool>,
399 tracks: Option<u32>,
400}
401
402fn render_search(r: &SearchResults) -> SearchOutput {
403 SearchOutput {
404 tracks: r.tracks.as_ref().map(|p| {
405 p.items
406 .iter()
407 .map(|t| TrackOut {
408 id: t.id.clone(),
409 name: t.name.clone(),
410 uri: t.uri.clone(),
411 artists: t.artists.iter().map(|a| a.name.clone()).collect(),
412 album: t.album.as_ref().map(|a| a.name.clone()),
413 explicit: t.explicit,
414 })
415 .collect()
416 }),
417 albums: r.albums.as_ref().map(|p| {
418 p.items
419 .iter()
420 .map(|a| AlbumOut {
421 id: a.id.clone(),
422 name: a.name.clone(),
423 uri: a.uri.clone(),
424 release_date: a.release_date.clone(),
425 artists: a.artists.iter().map(|x| x.name.clone()).collect(),
426 total_tracks: a.total_tracks,
427 })
428 .collect()
429 }),
430 artists: r.artists.as_ref().map(|p| {
431 p.items
432 .iter()
433 .map(|a| ArtistOut {
434 id: a.id.clone(),
435 name: a.name.clone(),
436 uri: a.uri.clone(),
437 genres: a.genres.clone(),
438 })
439 .collect()
440 }),
441 playlists: r.playlists.as_ref().map(|p| {
442 p.items
443 .iter()
444 .map(|pl| PlaylistOut {
445 id: pl.id.clone(),
446 name: pl.name.clone(),
447 uri: pl.uri.clone(),
448 owner: pl.owner.as_ref().map(|o| o.id.clone()),
449 public: pl.public,
450 tracks: pl.items.as_ref().and_then(|t| t.total),
451 })
452 .collect()
453 }),
454 }
455}
456
457fn print_search_human(r: &SearchResults) {
458 if let Some(p) = &r.tracks {
459 if !p.items.is_empty() {
460 println!("Tracks");
461 for t in &p.items {
462 let artists = t
463 .artists
464 .iter()
465 .map(|a| a.name.as_str())
466 .collect::<Vec<_>>()
467 .join(", ");
468 let album = t.album.as_ref().map(|a| a.name.as_str()).unwrap_or("");
469 println!(" {:25} {} — {} [{album}]", t.id, t.name, artists);
470 }
471 }
472 }
473 if let Some(p) = &r.albums {
474 if !p.items.is_empty() {
475 println!("Albums");
476 for a in &p.items {
477 let artists = a
478 .artists
479 .iter()
480 .map(|x| x.name.as_str())
481 .collect::<Vec<_>>()
482 .join(", ");
483 let date = a.release_date.as_deref().unwrap_or("?");
484 println!(" {:25} {} — {} ({date})", a.id, a.name, artists);
485 }
486 }
487 }
488 if let Some(p) = &r.artists {
489 if !p.items.is_empty() {
490 println!("Artists");
491 for a in &p.items {
492 println!(" {:25} {}", a.id, a.name);
493 }
494 }
495 }
496 if let Some(p) = &r.playlists {
497 if !p.items.is_empty() {
498 println!("Playlists");
499 for pl in &p.items {
500 let owner = pl.owner.as_ref().map(|o| o.id.as_str()).unwrap_or("?");
501 println!(" {:25} {} (by {owner})", pl.id, pl.name);
502 }
503 }
504 }
505}
506
507async fn run_playlists_list(args: PlaylistsListArgs) -> Result<()> {
512 let permissions = perms::load_effective()?;
513 permissions.check_time(SpotifyFunction::PlaylistsRead)?;
514
515 let http = http_for()?;
516 let items = http.list_my_playlists(Some(args.limit)).await?;
517 let filtered: Vec<&PlaylistSummary> = items
518 .iter()
519 .filter(|p| {
520 permissions
521 .check_target(SpotifyFunction::PlaylistsRead, &p.name)
522 .is_ok()
523 })
524 .collect();
525
526 if args.json {
527 println!("{}", serde_json::to_string_pretty(&filtered).unwrap());
528 return Ok(());
529 }
530 if filtered.is_empty() {
531 println!("No playlists visible (or all filtered by permissions).");
532 return Ok(());
533 }
534 for p in &filtered {
535 let total = p.items.as_ref().and_then(|t| t.total).unwrap_or(0);
536 let owner = p.owner.as_ref().map(|o| o.id.as_str()).unwrap_or("?");
537 println!(" {:25} {} ({total} tracks, by {owner})", p.id, p.name);
538 }
539 Ok(())
540}
541
542async fn run_playlists_show(args: PlaylistsShowArgs) -> Result<()> {
543 let permissions = perms::load_effective()?;
544 permissions.check_time(SpotifyFunction::PlaylistsRead)?;
545
546 let (cfg, _label, _scope, _path) = effective_config()?;
547 let raw = playlist_target(args.playlist.as_deref(), cfg.default_playlist.as_deref())?;
548 permissions.check_target(SpotifyFunction::PlaylistsRead, &raw)?;
549 let resolved = strip_playlist_uri(&raw);
550
551 let http = http_for()?;
552 let summary = http.get_playlist(&resolved).await?;
553 let tracks = http
554 .get_playlist_tracks(&resolved, Some(args.limit))
555 .await?;
556
557 if args.json {
558 let out = serde_json::json!({ "playlist": summary, "tracks": tracks });
559 println!("{}", serde_json::to_string_pretty(&out).unwrap());
560 return Ok(());
561 }
562 println!("id : {}", summary.id);
563 println!("name : {}", summary.name);
564 if let Some(d) = &summary.description {
565 if !d.is_empty() {
566 println!("description : {d}");
567 }
568 }
569 if let Some(o) = &summary.owner {
570 println!("owner : {}", o.id);
571 }
572 println!("tracks :");
573 for t in &tracks {
574 print_playlist_track(t);
575 }
576 Ok(())
577}
578
579fn print_playlist_track(entry: &PlaylistTrackItem) {
580 let Some(track) = &entry.item else {
581 return;
582 };
583 let artists = track
584 .artists
585 .iter()
586 .map(|a| a.name.as_str())
587 .collect::<Vec<_>>()
588 .join(", ");
589 let album = track.album.as_ref().map(|a| a.name.as_str()).unwrap_or("");
590 println!(" {:25} {} — {} [{album}]", track.id, track.name, artists);
591}
592
593async fn run_playlists_create(args: PlaylistsCreateArgs) -> Result<()> {
594 let permissions = perms::load_effective()?;
595 permissions.check_time(SpotifyFunction::PlaylistsWrite)?;
596 permissions.check_target(SpotifyFunction::PlaylistsWrite, &args.name)?;
597 if let Some(d) = &args.description {
598 permissions.check_body(SpotifyFunction::PlaylistsWrite, d)?;
599 }
600
601 let http = http_for()?;
602 let summary = http
603 .create_playlist(&args.name, args.description.as_deref(), args.public)
604 .await?;
605
606 if args.json {
607 println!("{}", serde_json::to_string_pretty(&summary).unwrap());
608 } else {
609 println!("Created playlist `{}` (id={})", summary.name, summary.id);
610 }
611 Ok(())
612}
613
614async fn run_playlists_rename(args: PlaylistsRenameArgs) -> Result<()> {
615 let permissions = perms::load_effective()?;
616 permissions.check_time(SpotifyFunction::PlaylistsWrite)?;
617 permissions.check_target(SpotifyFunction::PlaylistsWrite, &args.playlist)?;
618 permissions.check_target(SpotifyFunction::PlaylistsWrite, &args.new_name)?;
619
620 let resolved = strip_playlist_uri(&args.playlist);
621 let http = http_for()?;
622 http.rename_playlist(&resolved, &args.new_name).await?;
623
624 if args.json {
625 let out = serde_json::json!({ "id": resolved, "new_name": args.new_name });
626 println!("{}", serde_json::to_string_pretty(&out).unwrap());
627 } else {
628 println!("Renamed `{resolved}` → `{}`", args.new_name);
629 }
630 Ok(())
631}
632
633async fn run_playlists_delete(args: PlaylistsDeleteArgs) -> Result<()> {
634 let permissions = perms::load_effective()?;
635 permissions.check_time(SpotifyFunction::PlaylistsWrite)?;
636 permissions.check_target(SpotifyFunction::PlaylistsWrite, &args.playlist)?;
637
638 let resolved = strip_playlist_uri(&args.playlist);
639 let http = http_for()?;
640 http.unfollow_playlist(&resolved).await?;
641
642 if args.json {
643 let out = serde_json::json!({ "id": resolved, "unfollowed": true });
644 println!("{}", serde_json::to_string_pretty(&out).unwrap());
645 } else {
646 println!("Unfollowed (deleted) playlist `{resolved}`");
647 }
648 Ok(())
649}
650
651async fn run_playlists_add(args: PlaylistsAddArgs) -> Result<()> {
652 let permissions = perms::load_effective()?;
653 permissions.check_time(SpotifyFunction::PlaylistsWrite)?;
654 permissions.check_target(SpotifyFunction::PlaylistsWrite, &args.playlist)?;
655 for t in &args.tracks {
656 permissions.check_target(SpotifyFunction::PlaylistsWrite, t)?;
657 }
658
659 let resolved = strip_playlist_uri(&args.playlist);
660 let uris = normalize_track_uris(&args.tracks);
661 let http = http_for()?;
662 http.add_playlist_tracks(&resolved, &uris).await?;
663
664 if args.json {
665 let out = serde_json::json!({ "id": resolved, "added": uris });
666 println!("{}", serde_json::to_string_pretty(&out).unwrap());
667 } else {
668 println!("Added {} track(s) to `{resolved}`", uris.len());
669 }
670 Ok(())
671}
672
673async fn run_playlists_remove(args: PlaylistsRemoveArgs) -> Result<()> {
674 let permissions = perms::load_effective()?;
675 permissions.check_time(SpotifyFunction::PlaylistsWrite)?;
676 permissions.check_target(SpotifyFunction::PlaylistsWrite, &args.playlist)?;
677 for t in &args.tracks {
678 permissions.check_target(SpotifyFunction::PlaylistsWrite, t)?;
679 }
680
681 let resolved = strip_playlist_uri(&args.playlist);
682 let uris = normalize_track_uris(&args.tracks);
683 let http = http_for()?;
684 http.remove_playlist_tracks(&resolved, &uris).await?;
685
686 if args.json {
687 let out = serde_json::json!({ "id": resolved, "removed": uris });
688 println!("{}", serde_json::to_string_pretty(&out).unwrap());
689 } else {
690 println!("Removed {} track(s) from `{resolved}`", uris.len());
691 }
692 Ok(())
693}
694
695async fn run_library_tracks_list(args: LibraryListArgs) -> Result<()> {
700 let permissions = perms::load_effective()?;
701 permissions.check_time(SpotifyFunction::LibraryRead)?;
702
703 let http = http_for()?;
704 let items = http.list_saved_tracks(Some(args.limit)).await?;
705 let filtered: Vec<&SavedTrack> = items
706 .iter()
707 .filter(|s| {
708 s.track
709 .uri
710 .as_deref()
711 .map(|u| {
712 permissions
713 .check_target(SpotifyFunction::LibraryRead, u)
714 .is_ok()
715 })
716 .unwrap_or(true)
717 })
718 .collect();
719
720 if args.json {
721 println!("{}", serde_json::to_string_pretty(&filtered).unwrap());
722 return Ok(());
723 }
724 if filtered.is_empty() {
725 println!("No saved tracks (or all filtered by permissions).");
726 return Ok(());
727 }
728 for s in &filtered {
729 let artists = s
730 .track
731 .artists
732 .iter()
733 .map(|a| a.name.as_str())
734 .collect::<Vec<_>>()
735 .join(", ");
736 println!(" {:25} {} — {}", s.track.id, s.track.name, artists);
737 }
738 Ok(())
739}
740
741async fn run_library_tracks_save(args: LibraryMutateArgs) -> Result<()> {
742 library_mutate_tracks(args, true).await
743}
744
745async fn run_library_tracks_unsave(args: LibraryMutateArgs) -> Result<()> {
746 library_mutate_tracks(args, false).await
747}
748
749async fn library_mutate_tracks(args: LibraryMutateArgs, save: bool) -> Result<()> {
750 let permissions = perms::load_effective()?;
751 permissions.check_time(SpotifyFunction::LibraryWrite)?;
752 for u in &args.items {
753 permissions.check_target(SpotifyFunction::LibraryWrite, u)?;
754 }
755
756 let uris = normalize_uris(&args.items, "track");
757 let http = http_for()?;
758 if save {
759 http.save_tracks(&uris).await?;
760 } else {
761 http.unsave_tracks(&uris).await?;
762 }
763
764 let verb = if save { "saved" } else { "unsaved" };
765 if args.json {
766 let out = serde_json::json!({ verb: uris });
767 println!("{}", serde_json::to_string_pretty(&out).unwrap());
768 } else {
769 println!("{verb} {} track(s)", uris.len());
770 }
771 Ok(())
772}
773
774async fn run_library_albums_list(args: LibraryListArgs) -> Result<()> {
775 let permissions = perms::load_effective()?;
776 permissions.check_time(SpotifyFunction::LibraryRead)?;
777
778 let http = http_for()?;
779 let items = http.list_saved_albums(Some(args.limit)).await?;
780 let filtered: Vec<&SavedAlbum> = items
781 .iter()
782 .filter(|s| {
783 s.album
784 .uri
785 .as_deref()
786 .map(|u| {
787 permissions
788 .check_target(SpotifyFunction::LibraryRead, u)
789 .is_ok()
790 })
791 .unwrap_or(true)
792 })
793 .collect();
794
795 if args.json {
796 println!("{}", serde_json::to_string_pretty(&filtered).unwrap());
797 return Ok(());
798 }
799 if filtered.is_empty() {
800 println!("No saved albums (or all filtered by permissions).");
801 return Ok(());
802 }
803 for s in &filtered {
804 let artists = s
805 .album
806 .artists
807 .iter()
808 .map(|a| a.name.as_str())
809 .collect::<Vec<_>>()
810 .join(", ");
811 let date = s.album.release_date.as_deref().unwrap_or("?");
812 println!(
813 " {:25} {} — {} ({date})",
814 s.album.id, s.album.name, artists
815 );
816 }
817 Ok(())
818}
819
820async fn run_library_albums_save(args: LibraryMutateArgs) -> Result<()> {
821 library_mutate_albums(args, true).await
822}
823
824async fn run_library_albums_unsave(args: LibraryMutateArgs) -> Result<()> {
825 library_mutate_albums(args, false).await
826}
827
828async fn library_mutate_albums(args: LibraryMutateArgs, save: bool) -> Result<()> {
829 let permissions = perms::load_effective()?;
830 permissions.check_time(SpotifyFunction::LibraryWrite)?;
831 for u in &args.items {
832 permissions.check_target(SpotifyFunction::LibraryWrite, u)?;
833 }
834
835 let uris = normalize_uris(&args.items, "album");
836 let http = http_for()?;
837 if save {
838 http.save_albums(&uris).await?;
839 } else {
840 http.unsave_albums(&uris).await?;
841 }
842
843 let verb = if save { "saved" } else { "unsaved" };
844 if args.json {
845 let out = serde_json::json!({ verb: uris });
846 println!("{}", serde_json::to_string_pretty(&out).unwrap());
847 } else {
848 println!("{verb} {} album(s)", uris.len());
849 }
850 Ok(())
851}
852
853fn run_permissions(args: PermissionsArgs) -> Result<()> {
858 match args.action {
859 None => run_permissions_show(PermissionsShowArgs { json: args.json }),
860 Some(PermissionsAction::Show(a)) => run_permissions_show(a),
861 Some(PermissionsAction::Path(a)) => run_permissions_path(a),
862 Some(PermissionsAction::Init(a)) => run_permissions_init(a),
863 Some(PermissionsAction::Check(a)) => run_permissions_check(a),
864 Some(PermissionsAction::Staging(a)) => {
865 crate::cli::permissions::run::<perms::PermissionsService>(a)
866 }
867 }
868}
869
870#[derive(Debug, Serialize)]
871struct PermissionsScopeOut {
872 path: String,
873 present: bool,
874}
875
876#[derive(Debug, Serialize)]
877struct PermissionsShowOut {
878 command: &'static str,
879 global: PermissionsScopeOut,
880 local: PermissionsScopeOut,
881}
882
883fn run_permissions_show(args: PermissionsShowArgs) -> Result<()> {
884 let global_path = perms::global_path()?;
885 let local_path = perms::local_path_current()?;
886 let _ = perms::load_effective()?;
888
889 if args.json {
890 let out = PermissionsShowOut {
891 command: "spotify.permissions.show",
892 global: PermissionsScopeOut {
893 path: global_path.display().to_string(),
894 present: global_path.exists(),
895 },
896 local: PermissionsScopeOut {
897 path: local_path.display().to_string(),
898 present: local_path.exists(),
899 },
900 };
901 println!("{}", serde_json::to_string_pretty(&out).unwrap());
902 return Ok(());
903 }
904 println!("Spotify permissions");
905 print_scope_block("global", &global_path);
906 print_scope_block("local", &local_path);
907 Ok(())
908}
909
910fn print_scope_block(label: &str, path: &Path) {
911 println!();
912 println!(" [{label}] {}", path.display());
913 if !path.exists() {
914 println!(" (no file at this scope)");
915 return;
916 }
917 match std::fs::read_to_string(path) {
918 Ok(body) => {
919 for line in body.lines() {
920 println!(" {line}");
921 }
922 }
923 Err(e) => println!(" (could not read: {e})"),
924 }
925}
926
927fn run_permissions_path(args: PermissionsPathArgs) -> Result<()> {
928 let global_path = perms::global_path()?;
929 let local_path = perms::local_path_current()?;
930 if args.json {
931 let out = serde_json::json!({
932 "command": "spotify.permissions.path",
933 "global": global_path.display().to_string(),
934 "local": local_path.display().to_string(),
935 });
936 println!("{}", serde_json::to_string_pretty(&out).unwrap());
937 } else {
938 println!("{}", global_path.display());
939 println!("{}", local_path.display());
940 }
941 Ok(())
942}
943
944#[derive(Debug, Serialize)]
945struct PermissionsInitOutput {
946 command: &'static str,
947 scope: &'static str,
948 path: String,
949 written: bool,
950}
951
952fn run_permissions_init(args: PermissionsInitArgs) -> Result<()> {
953 let (path, scope) = if args.local {
954 (perms::local_path_current()?, "local")
955 } else {
956 (perms::global_path()?, "global")
957 };
958 if path.exists() && !args.force {
959 return Err(ZadError::Invalid(format!(
960 "permissions file already exists at {}. Pass --force to overwrite.",
961 path.display()
962 )));
963 }
964 let template = perms::starter_template();
965 let key = zad::permissions::signing::load_or_create_from_keychain()?;
966 zad::permissions::signing::write_public_key_cache(&key)?;
967 perms::save_file(&path, &template, &key)?;
968 if args.json {
969 let out = PermissionsInitOutput {
970 command: "spotify.permissions.init",
971 scope,
972 path: path.display().to_string(),
973 written: true,
974 };
975 println!("{}", serde_json::to_string_pretty(&out).unwrap());
976 } else {
977 println!("Wrote starter permissions ({scope}): {}", path.display());
978 println!("Signed with key {}.", key.fingerprint());
979 println!("Review it; the defaults deny `*release*`/`*official*` playlists.");
980 }
981 Ok(())
982}
983
984fn run_permissions_check(args: PermissionsCheckArgs) -> Result<()> {
985 let f = parse_function(&args.function)?;
986 let permissions = perms::load_effective()?;
987
988 permissions.check_time(f)?;
989 if let Some(t) = &args.target {
990 permissions.check_target(f, t)?;
991 }
992 if let Some(b) = &args.body {
993 permissions.check_body(f, b)?;
994 }
995
996 if args.json {
997 let out = serde_json::json!({
998 "command": "spotify.permissions.check",
999 "function": args.function,
1000 "ok": true,
1001 });
1002 println!("{}", serde_json::to_string_pretty(&out).unwrap());
1003 } else {
1004 println!("✓ would be allowed by current spotify permissions");
1005 }
1006 Ok(())
1007}
1008
1009fn parse_function(name: &str) -> Result<SpotifyFunction> {
1010 Ok(match name {
1011 "search" => SpotifyFunction::Search,
1012 "playlists_read" => SpotifyFunction::PlaylistsRead,
1013 "playlists_write" => SpotifyFunction::PlaylistsWrite,
1014 "library_read" => SpotifyFunction::LibraryRead,
1015 "library_write" => SpotifyFunction::LibraryWrite,
1016 other => {
1017 return Err(ZadError::Invalid(format!(
1018 "unknown function `{other}`; expected one of: search, playlists_read, \
1019 playlists_write, library_read, library_write"
1020 )));
1021 }
1022 })
1023}
1024
1025pub(crate) fn effective_config()
1030-> Result<(SpotifyServiceCfg, &'static str, Scope<'static>, PathBuf)> {
1031 let slug = config::path::project_slug()?;
1032 let local_path = config::path::project_service_config_path_for(&slug, "spotify")?;
1033 let global_path = config::path::global_service_config_path("spotify")?;
1034
1035 let project_cfg = config::load()?;
1036 if !project_cfg.has_service("spotify") {
1037 return Err(ZadError::Invalid(format!(
1038 "spotify is not enabled for this project ({}). \
1039 Run `zad service enable spotify` first.",
1040 config::path::project_config_path()?.display()
1041 )));
1042 }
1043
1044 if let Some(cfg) = config::load_flat::<SpotifyServiceCfg>(&local_path)? {
1045 let slug_leaked = leak(slug);
1046 return Ok((cfg, "local", Scope::Project(slug_leaked), local_path));
1047 }
1048 if let Some(cfg) = config::load_flat::<SpotifyServiceCfg>(&global_path)? {
1049 return Ok((cfg, "global", Scope::Global, global_path));
1050 }
1051 Err(ZadError::Invalid(format!(
1052 "no spotify credentials found.\n looked in:\n {}\n {}\n \
1053 Run `zad service create spotify`.",
1054 local_path.display(),
1055 global_path.display()
1056 )))
1057}
1058
1059fn http_for() -> Result<SpotifyHttp> {
1060 let (cfg, _label, scope, path) = effective_config()?;
1061 let client_id = secrets::load(&secrets::account("spotify", "client-id", scope.clone()))?
1062 .ok_or(ZadError::Service {
1063 name: "spotify",
1064 message: "client-id missing from keychain; re-run `zad service create spotify`".into(),
1065 })?;
1066 let refresh_token = secrets::load(&secrets::account("spotify", "refresh", scope))?.ok_or(
1067 ZadError::Service {
1068 name: "spotify",
1069 message: "refresh token missing from keychain; re-run `zad service create spotify`"
1070 .into(),
1071 },
1072 )?;
1073 let scope_set: BTreeSet<String> = cfg.scopes.iter().cloned().collect();
1074 Ok(SpotifyHttp::new(client_id, refresh_token, scope_set, path))
1075}
1076
1077fn playlist_target(flag: Option<&str>, default: Option<&str>) -> Result<String> {
1082 if let Some(v) = flag {
1083 return Ok(v.to_string());
1084 }
1085 if let Some(v) = default {
1086 return Ok(v.to_string());
1087 }
1088 Err(ZadError::MissingRequired(
1089 "--playlist (or set `default_playlist` in the spotify config)",
1090 ))
1091}
1092
1093fn strip_playlist_uri(s: &str) -> String {
1096 s.strip_prefix("spotify:playlist:")
1097 .or_else(|| s.strip_prefix("spotify:track:"))
1098 .or_else(|| s.strip_prefix("spotify:album:"))
1099 .unwrap_or(s)
1100 .to_string()
1101}
1102
1103fn normalize_track_uris(items: &[String]) -> Vec<String> {
1107 normalize_uris(items, "track")
1108}
1109
1110fn normalize_uris(items: &[String], kind: &str) -> Vec<String> {
1117 items
1118 .iter()
1119 .map(|s| {
1120 if s.starts_with("spotify:") {
1121 s.clone()
1122 } else {
1123 format!("spotify:{kind}:{s}")
1124 }
1125 })
1126 .collect()
1127}