Skip to main content

zad_cli/cli/
spotify.rs

1//! `zad spotify <verb>` — runtime surface for the Spotify service.
2//!
3//! Wires together:
4//! - per-verb clap args (`search`, `playlists list/show/create/
5//!   rename/delete/add/remove`, `library tracks/albums {list,save,
6//!   unsave}`, plus the mandatory `permissions` subgroup);
7//! - credential + scope resolution from the effective config (local
8//!   wins over global);
9//! - permission gating (time window → target → content) executed
10//!   **before** any network call.
11//!
12//! No `--dry-run` for now — playlist mutations are reversible
13//! (Spotify keeps a 90-day history) and search / library reads are
14//! side-effect-free.
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, 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// ---------------------------------------------------------------------------
32// top-level args
33// ---------------------------------------------------------------------------
34
35#[derive(Debug, Args)]
36pub struct SpotifyArgs {
37    #[command(subcommand)]
38    pub action: Action,
39}
40
41#[derive(Debug, Subcommand)]
42pub enum Action {
43    /// Search the Spotify catalogue (tracks, albums, artists, playlists).
44    Search(SearchArgs),
45    /// Playlist management (list, show, create, rename, delete, add, remove).
46    Playlists(PlaylistsArgs),
47    /// Library management (saved tracks and albums).
48    Library(LibraryArgs),
49    /// Inspect or scaffold the permissions policy.
50    Permissions(PermissionsArgs),
51}
52
53// ---------------------------------------------------------------------------
54// `zad spotify search …`
55// ---------------------------------------------------------------------------
56
57#[derive(Debug, Args)]
58pub struct SearchArgs {
59    /// Free-text query.
60    pub query: String,
61    /// One or more entity types to search across (`track`, `album`,
62    /// `artist`, `playlist`). Repeatable. Defaults to `track`.
63    #[arg(long = "type", value_parser = ["track", "album", "artist", "playlist"], default_values = ["track"])]
64    pub types: Vec<String>,
65    /// Page size (1..=50). Spotify caps every request at 50 items.
66    #[arg(long, default_value_t = 20)]
67    pub limit: u32,
68    #[arg(long)]
69    pub json: bool,
70}
71
72// ---------------------------------------------------------------------------
73// `zad spotify playlists …`
74// ---------------------------------------------------------------------------
75
76#[derive(Debug, Args)]
77pub struct PlaylistsArgs {
78    #[command(subcommand)]
79    pub action: PlaylistsAction,
80}
81
82#[derive(Debug, Subcommand)]
83pub enum PlaylistsAction {
84    /// List the authenticated user's playlists.
85    List(PlaylistsListArgs),
86    /// Show one playlist's metadata and tracks.
87    Show(PlaylistsShowArgs),
88    /// Create a new playlist owned by the authenticated user.
89    Create(PlaylistsCreateArgs),
90    /// Rename an existing playlist.
91    Rename(PlaylistsRenameArgs),
92    /// Delete (i.e. unfollow) a playlist owned by the user.
93    Delete(PlaylistsDeleteArgs),
94    /// Add one or more tracks to a playlist.
95    Add(PlaylistsAddArgs),
96    /// Remove one or more tracks from a playlist.
97    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    /// Playlist ID, `spotify:playlist:<id>` URI, or — when previously
111    /// listed — the literal name of an owned playlist.
112    pub playlist: Option<String>,
113    /// Page size for the tracks listing (1..=100).
114    #[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    /// Display name for the new playlist.
123    pub name: String,
124    #[arg(long)]
125    pub description: Option<String>,
126    /// Make the playlist public (default: private).
127    #[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    /// Target playlist (ID, URI, or owned-playlist name).
151    pub playlist: String,
152    /// One or more track URIs (`spotify:track:<id>`) or bare track IDs.
153    #[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// ---------------------------------------------------------------------------
169// `zad spotify library …`
170// ---------------------------------------------------------------------------
171
172#[derive(Debug, Args)]
173pub struct LibraryArgs {
174    #[command(subcommand)]
175    pub action: LibraryAction,
176}
177
178#[derive(Debug, Subcommand)]
179pub enum LibraryAction {
180    /// Saved-tracks operations.
181    Tracks(LibraryTracksArgs),
182    /// Saved-albums operations.
183    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 saved items.
201    List(LibraryListArgs),
202    /// Save (like) one or more items.
203    Save(LibraryMutateArgs),
204    /// Unsave (unlike) one or more items.
205    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    /// One or more URIs (`spotify:track:<id>` or `spotify:album:<id>`)
219    /// or bare IDs.
220    #[arg(required = true)]
221    pub items: Vec<String>,
222    #[arg(long)]
223    pub json: bool,
224}
225
226// ---------------------------------------------------------------------------
227// `zad spotify permissions …`
228// ---------------------------------------------------------------------------
229
230#[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    /// Print the effective policy (both file paths + bodies).
242    Show(PermissionsShowArgs),
243    /// Print the two candidate file paths, one per line.
244    Path(PermissionsPathArgs),
245    /// Write a starter policy to the selected scope.
246    Init(PermissionsInitArgs),
247    /// Dry-run a permissions check without hitting the network.
248    Check(PermissionsCheckArgs),
249    /// Staged-commit workflow: queue mutations in a `.pending` file and
250    /// only sign on `commit`. See `cli::permissions`.
251    #[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    /// Function name: `search`, `playlists_read`, `playlists_write`,
280    /// `library_read`, or `library_write`.
281    #[arg(long)]
282    pub function: String,
283    /// Target to check against the function's `targets` list — a
284    /// playlist name/ID/URI, a track/album URI, or a search query.
285    #[arg(long)]
286    pub target: Option<String>,
287    /// Body text to evaluate against the function's content rules
288    /// (e.g. a search query, a playlist description).
289    #[arg(long)]
290    pub body: Option<String>,
291    #[arg(long)]
292    pub json: bool,
293}
294
295// ---------------------------------------------------------------------------
296// dispatch
297// ---------------------------------------------------------------------------
298
299pub 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
327// ---------------------------------------------------------------------------
328// search
329// ---------------------------------------------------------------------------
330
331async 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
507// ---------------------------------------------------------------------------
508// playlists
509// ---------------------------------------------------------------------------
510
511async 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
695// ---------------------------------------------------------------------------
696// library
697// ---------------------------------------------------------------------------
698
699async 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
853// ---------------------------------------------------------------------------
854// permissions subgroup — show / path / init / check
855// ---------------------------------------------------------------------------
856
857fn 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    // Force compile so syntax errors surface immediately.
887    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
1025// ---------------------------------------------------------------------------
1026// shared helpers
1027// ---------------------------------------------------------------------------
1028
1029pub(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
1077/// Resolve `--playlist <raw>` against `default_playlist` fallback.
1078/// Returns the raw string the user typed (or the configured default);
1079/// callers strip any `spotify:playlist:` prefix before hitting the
1080/// API via [`strip_playlist_uri`].
1081fn 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
1093/// Strip `spotify:playlist:` (or `spotify:track:` / `spotify:album:`)
1094/// prefixes from a URI, returning the bare ID. Bare IDs pass through.
1095fn 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
1103/// Normalise a list of user-supplied track refs into URIs the API
1104/// accepts on the playlist add / remove endpoints. Bare IDs get the
1105/// `spotify:track:` prefix; full URIs pass through.
1106fn normalize_track_uris(items: &[String]) -> Vec<String> {
1107    normalize_uris(items, "track")
1108}
1109
1110/// Normalise user-supplied refs to fully-qualified `spotify:<kind>:<id>`
1111/// URIs — the form `PUT/DELETE /me/library`, `POST /playlists/{id}/items`,
1112/// and `DELETE /playlists/{id}/items` all expect after February 2026.
1113/// `kind` is `"track"`, `"album"`, `"show"`, …; it is only used to add
1114/// the prefix to bare IDs. Anything already starting with `spotify:` is
1115/// passed through verbatim.
1116fn 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}