use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use clap::{Args, Subcommand};
use serde::Serialize;
use crate::cli::lifecycle::leak;
use zad::config::{self, SpotifyServiceCfg};
use zad::error::{Result, ZadError};
use zad::secrets::{self, Scope};
use zad::service::spotify::client::{
PlaylistSummary, PlaylistTrackItem, SavedAlbum, SavedTrack, SearchResults, SpotifyHttp,
};
use zad::service::spotify::permissions::{self as perms, SpotifyFunction};
#[derive(Debug, Args)]
pub struct SpotifyArgs {
#[command(subcommand)]
pub action: Action,
}
#[derive(Debug, Subcommand)]
pub enum Action {
Search(SearchArgs),
Playlists(PlaylistsArgs),
Library(LibraryArgs),
Permissions(PermissionsArgs),
}
#[derive(Debug, Args)]
pub struct SearchArgs {
pub query: String,
#[arg(long = "type", value_parser = ["track", "album", "artist", "playlist"], default_values = ["track"])]
pub types: Vec<String>,
#[arg(long, default_value_t = 20)]
pub limit: u32,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct PlaylistsArgs {
#[command(subcommand)]
pub action: PlaylistsAction,
}
#[derive(Debug, Subcommand)]
pub enum PlaylistsAction {
List(PlaylistsListArgs),
Show(PlaylistsShowArgs),
Create(PlaylistsCreateArgs),
Rename(PlaylistsRenameArgs),
Delete(PlaylistsDeleteArgs),
Add(PlaylistsAddArgs),
Remove(PlaylistsRemoveArgs),
}
#[derive(Debug, Args)]
pub struct PlaylistsListArgs {
#[arg(long, default_value_t = 20)]
pub limit: u32,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct PlaylistsShowArgs {
pub playlist: Option<String>,
#[arg(long, default_value_t = 50)]
pub limit: u32,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct PlaylistsCreateArgs {
pub name: String,
#[arg(long)]
pub description: Option<String>,
#[arg(long)]
pub public: bool,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct PlaylistsRenameArgs {
pub playlist: String,
pub new_name: String,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct PlaylistsDeleteArgs {
pub playlist: String,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct PlaylistsAddArgs {
pub playlist: String,
#[arg(required = true)]
pub tracks: Vec<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct PlaylistsRemoveArgs {
pub playlist: String,
#[arg(required = true)]
pub tracks: Vec<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct LibraryArgs {
#[command(subcommand)]
pub action: LibraryAction,
}
#[derive(Debug, Subcommand)]
pub enum LibraryAction {
Tracks(LibraryTracksArgs),
Albums(LibraryAlbumsArgs),
}
#[derive(Debug, Args)]
pub struct LibraryTracksArgs {
#[command(subcommand)]
pub action: LibraryItemAction,
}
#[derive(Debug, Args)]
pub struct LibraryAlbumsArgs {
#[command(subcommand)]
pub action: LibraryItemAction,
}
#[derive(Debug, Subcommand)]
pub enum LibraryItemAction {
List(LibraryListArgs),
Save(LibraryMutateArgs),
Unsave(LibraryMutateArgs),
}
#[derive(Debug, Args)]
pub struct LibraryListArgs {
#[arg(long, default_value_t = 20)]
pub limit: u32,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct LibraryMutateArgs {
#[arg(required = true)]
pub items: Vec<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct PermissionsArgs {
#[command(subcommand)]
pub action: Option<PermissionsAction>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Subcommand)]
#[allow(clippy::large_enum_variant)]
pub enum PermissionsAction {
Show(PermissionsShowArgs),
Path(PermissionsPathArgs),
Init(PermissionsInitArgs),
Check(PermissionsCheckArgs),
#[command(flatten)]
Staging(crate::cli::permissions::StagingAction),
}
#[derive(Debug, Args)]
pub struct PermissionsShowArgs {
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct PermissionsPathArgs {
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct PermissionsInitArgs {
#[arg(long)]
pub local: bool,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct PermissionsCheckArgs {
#[arg(long)]
pub function: String,
#[arg(long)]
pub target: Option<String>,
#[arg(long)]
pub body: Option<String>,
#[arg(long)]
pub json: bool,
}
pub async fn run(args: SpotifyArgs) -> Result<()> {
match args.action {
Action::Search(a) => run_search(a).await,
Action::Playlists(a) => match a.action {
PlaylistsAction::List(a) => run_playlists_list(a).await,
PlaylistsAction::Show(a) => run_playlists_show(a).await,
PlaylistsAction::Create(a) => run_playlists_create(a).await,
PlaylistsAction::Rename(a) => run_playlists_rename(a).await,
PlaylistsAction::Delete(a) => run_playlists_delete(a).await,
PlaylistsAction::Add(a) => run_playlists_add(a).await,
PlaylistsAction::Remove(a) => run_playlists_remove(a).await,
},
Action::Library(a) => match a.action {
LibraryAction::Tracks(t) => match t.action {
LibraryItemAction::List(a) => run_library_tracks_list(a).await,
LibraryItemAction::Save(a) => run_library_tracks_save(a).await,
LibraryItemAction::Unsave(a) => run_library_tracks_unsave(a).await,
},
LibraryAction::Albums(t) => match t.action {
LibraryItemAction::List(a) => run_library_albums_list(a).await,
LibraryItemAction::Save(a) => run_library_albums_save(a).await,
LibraryItemAction::Unsave(a) => run_library_albums_unsave(a).await,
},
},
Action::Permissions(a) => run_permissions(a),
}
}
async fn run_search(args: SearchArgs) -> Result<()> {
let permissions = perms::load_effective()?;
permissions.check_time(SpotifyFunction::Search)?;
permissions.check_target(SpotifyFunction::Search, &args.query)?;
permissions.check_body(SpotifyFunction::Search, &args.query)?;
let http = http_for()?;
let types: Vec<&str> = args.types.iter().map(|s| s.as_str()).collect();
let results = http.search(&args.query, &types, args.limit).await?;
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&render_search(&results)).unwrap()
);
return Ok(());
}
print_search_human(&results);
Ok(())
}
#[derive(Debug, Serialize)]
struct SearchOutput {
#[serde(skip_serializing_if = "Option::is_none")]
tracks: Option<Vec<TrackOut>>,
#[serde(skip_serializing_if = "Option::is_none")]
albums: Option<Vec<AlbumOut>>,
#[serde(skip_serializing_if = "Option::is_none")]
artists: Option<Vec<ArtistOut>>,
#[serde(skip_serializing_if = "Option::is_none")]
playlists: Option<Vec<PlaylistOut>>,
}
#[derive(Debug, Serialize)]
struct TrackOut {
id: String,
name: String,
uri: Option<String>,
artists: Vec<String>,
album: Option<String>,
explicit: Option<bool>,
}
#[derive(Debug, Serialize)]
struct AlbumOut {
id: String,
name: String,
uri: Option<String>,
release_date: Option<String>,
artists: Vec<String>,
total_tracks: Option<u32>,
}
#[derive(Debug, Serialize)]
struct ArtistOut {
id: String,
name: String,
uri: Option<String>,
genres: Vec<String>,
}
#[derive(Debug, Serialize)]
struct PlaylistOut {
id: String,
name: String,
uri: Option<String>,
owner: Option<String>,
public: Option<bool>,
tracks: Option<u32>,
}
fn render_search(r: &SearchResults) -> SearchOutput {
SearchOutput {
tracks: r.tracks.as_ref().map(|p| {
p.items
.iter()
.map(|t| TrackOut {
id: t.id.clone(),
name: t.name.clone(),
uri: t.uri.clone(),
artists: t.artists.iter().map(|a| a.name.clone()).collect(),
album: t.album.as_ref().map(|a| a.name.clone()),
explicit: t.explicit,
})
.collect()
}),
albums: r.albums.as_ref().map(|p| {
p.items
.iter()
.map(|a| AlbumOut {
id: a.id.clone(),
name: a.name.clone(),
uri: a.uri.clone(),
release_date: a.release_date.clone(),
artists: a.artists.iter().map(|x| x.name.clone()).collect(),
total_tracks: a.total_tracks,
})
.collect()
}),
artists: r.artists.as_ref().map(|p| {
p.items
.iter()
.map(|a| ArtistOut {
id: a.id.clone(),
name: a.name.clone(),
uri: a.uri.clone(),
genres: a.genres.clone(),
})
.collect()
}),
playlists: r.playlists.as_ref().map(|p| {
p.items
.iter()
.map(|pl| PlaylistOut {
id: pl.id.clone(),
name: pl.name.clone(),
uri: pl.uri.clone(),
owner: pl.owner.as_ref().map(|o| o.id.clone()),
public: pl.public,
tracks: pl.tracks.as_ref().and_then(|t| t.total),
})
.collect()
}),
}
}
fn print_search_human(r: &SearchResults) {
if let Some(p) = &r.tracks {
if !p.items.is_empty() {
println!("Tracks");
for t in &p.items {
let artists = t
.artists
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ");
let album = t.album.as_ref().map(|a| a.name.as_str()).unwrap_or("");
println!(" {:25} {} — {} [{album}]", t.id, t.name, artists);
}
}
}
if let Some(p) = &r.albums {
if !p.items.is_empty() {
println!("Albums");
for a in &p.items {
let artists = a
.artists
.iter()
.map(|x| x.name.as_str())
.collect::<Vec<_>>()
.join(", ");
let date = a.release_date.as_deref().unwrap_or("?");
println!(" {:25} {} — {} ({date})", a.id, a.name, artists);
}
}
}
if let Some(p) = &r.artists {
if !p.items.is_empty() {
println!("Artists");
for a in &p.items {
println!(" {:25} {}", a.id, a.name);
}
}
}
if let Some(p) = &r.playlists {
if !p.items.is_empty() {
println!("Playlists");
for pl in &p.items {
let owner = pl.owner.as_ref().map(|o| o.id.as_str()).unwrap_or("?");
println!(" {:25} {} (by {owner})", pl.id, pl.name);
}
}
}
}
async fn run_playlists_list(args: PlaylistsListArgs) -> Result<()> {
let permissions = perms::load_effective()?;
permissions.check_time(SpotifyFunction::PlaylistsRead)?;
let http = http_for()?;
let items = http.list_my_playlists(args.limit).await?;
let filtered: Vec<&PlaylistSummary> = items
.iter()
.filter(|p| {
permissions
.check_target(SpotifyFunction::PlaylistsRead, &p.name)
.is_ok()
})
.collect();
if args.json {
println!("{}", serde_json::to_string_pretty(&filtered).unwrap());
return Ok(());
}
if filtered.is_empty() {
println!("No playlists visible (or all filtered by permissions).");
return Ok(());
}
for p in &filtered {
let total = p.tracks.as_ref().and_then(|t| t.total).unwrap_or(0);
let owner = p.owner.as_ref().map(|o| o.id.as_str()).unwrap_or("?");
println!(" {:25} {} ({total} tracks, by {owner})", p.id, p.name);
}
Ok(())
}
async fn run_playlists_show(args: PlaylistsShowArgs) -> Result<()> {
let permissions = perms::load_effective()?;
permissions.check_time(SpotifyFunction::PlaylistsRead)?;
let (cfg, _label, _scope, _path) = effective_config()?;
let raw = playlist_target(args.playlist.as_deref(), cfg.default_playlist.as_deref())?;
permissions.check_target(SpotifyFunction::PlaylistsRead, &raw)?;
let resolved = strip_playlist_uri(&raw);
let http = http_for()?;
let summary = http.get_playlist(&resolved).await?;
let tracks = http.get_playlist_tracks(&resolved, args.limit).await?;
if args.json {
let out = serde_json::json!({ "playlist": summary, "tracks": tracks });
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return Ok(());
}
println!("id : {}", summary.id);
println!("name : {}", summary.name);
if let Some(d) = &summary.description {
if !d.is_empty() {
println!("description : {d}");
}
}
if let Some(o) = &summary.owner {
println!("owner : {}", o.id);
}
println!("tracks :");
for t in &tracks {
print_playlist_track(t);
}
Ok(())
}
fn print_playlist_track(item: &PlaylistTrackItem) {
let Some(track) = &item.track else {
return;
};
let artists = track
.artists
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ");
let album = track.album.as_ref().map(|a| a.name.as_str()).unwrap_or("");
println!(" {:25} {} — {} [{album}]", track.id, track.name, artists);
}
async fn run_playlists_create(args: PlaylistsCreateArgs) -> Result<()> {
let permissions = perms::load_effective()?;
permissions.check_time(SpotifyFunction::PlaylistsWrite)?;
permissions.check_target(SpotifyFunction::PlaylistsWrite, &args.name)?;
if let Some(d) = &args.description {
permissions.check_body(SpotifyFunction::PlaylistsWrite, d)?;
}
let (cfg, _label, _scope, _path) = effective_config()?;
let user_id = cfg.self_user_id.clone().ok_or(ZadError::Invalid(
"self_user_id is unset; re-run `zad service create spotify` (it captures \
the user ID from `GET /me`) or pass --self-user."
.into(),
))?;
let http = http_for()?;
let summary = http
.create_playlist(
&user_id,
&args.name,
args.description.as_deref(),
args.public,
)
.await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&summary).unwrap());
} else {
println!("Created playlist `{}` (id={})", summary.name, summary.id);
}
Ok(())
}
async fn run_playlists_rename(args: PlaylistsRenameArgs) -> Result<()> {
let permissions = perms::load_effective()?;
permissions.check_time(SpotifyFunction::PlaylistsWrite)?;
permissions.check_target(SpotifyFunction::PlaylistsWrite, &args.playlist)?;
permissions.check_target(SpotifyFunction::PlaylistsWrite, &args.new_name)?;
let resolved = strip_playlist_uri(&args.playlist);
let http = http_for()?;
http.rename_playlist(&resolved, &args.new_name).await?;
if args.json {
let out = serde_json::json!({ "id": resolved, "new_name": args.new_name });
println!("{}", serde_json::to_string_pretty(&out).unwrap());
} else {
println!("Renamed `{resolved}` → `{}`", args.new_name);
}
Ok(())
}
async fn run_playlists_delete(args: PlaylistsDeleteArgs) -> Result<()> {
let permissions = perms::load_effective()?;
permissions.check_time(SpotifyFunction::PlaylistsWrite)?;
permissions.check_target(SpotifyFunction::PlaylistsWrite, &args.playlist)?;
let resolved = strip_playlist_uri(&args.playlist);
let http = http_for()?;
http.unfollow_playlist(&resolved).await?;
if args.json {
let out = serde_json::json!({ "id": resolved, "unfollowed": true });
println!("{}", serde_json::to_string_pretty(&out).unwrap());
} else {
println!("Unfollowed (deleted) playlist `{resolved}`");
}
Ok(())
}
async fn run_playlists_add(args: PlaylistsAddArgs) -> Result<()> {
let permissions = perms::load_effective()?;
permissions.check_time(SpotifyFunction::PlaylistsWrite)?;
permissions.check_target(SpotifyFunction::PlaylistsWrite, &args.playlist)?;
for t in &args.tracks {
permissions.check_target(SpotifyFunction::PlaylistsWrite, t)?;
}
let resolved = strip_playlist_uri(&args.playlist);
let uris = normalize_track_uris(&args.tracks);
let http = http_for()?;
http.add_playlist_tracks(&resolved, &uris).await?;
if args.json {
let out = serde_json::json!({ "id": resolved, "added": uris });
println!("{}", serde_json::to_string_pretty(&out).unwrap());
} else {
println!("Added {} track(s) to `{resolved}`", uris.len());
}
Ok(())
}
async fn run_playlists_remove(args: PlaylistsRemoveArgs) -> Result<()> {
let permissions = perms::load_effective()?;
permissions.check_time(SpotifyFunction::PlaylistsWrite)?;
permissions.check_target(SpotifyFunction::PlaylistsWrite, &args.playlist)?;
for t in &args.tracks {
permissions.check_target(SpotifyFunction::PlaylistsWrite, t)?;
}
let resolved = strip_playlist_uri(&args.playlist);
let uris = normalize_track_uris(&args.tracks);
let http = http_for()?;
http.remove_playlist_tracks(&resolved, &uris).await?;
if args.json {
let out = serde_json::json!({ "id": resolved, "removed": uris });
println!("{}", serde_json::to_string_pretty(&out).unwrap());
} else {
println!("Removed {} track(s) from `{resolved}`", uris.len());
}
Ok(())
}
async fn run_library_tracks_list(args: LibraryListArgs) -> Result<()> {
let permissions = perms::load_effective()?;
permissions.check_time(SpotifyFunction::LibraryRead)?;
let http = http_for()?;
let items = http.list_saved_tracks(args.limit).await?;
let filtered: Vec<&SavedTrack> = items
.iter()
.filter(|s| {
s.track
.uri
.as_deref()
.map(|u| {
permissions
.check_target(SpotifyFunction::LibraryRead, u)
.is_ok()
})
.unwrap_or(true)
})
.collect();
if args.json {
println!("{}", serde_json::to_string_pretty(&filtered).unwrap());
return Ok(());
}
if filtered.is_empty() {
println!("No saved tracks (or all filtered by permissions).");
return Ok(());
}
for s in &filtered {
let artists = s
.track
.artists
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ");
println!(" {:25} {} — {}", s.track.id, s.track.name, artists);
}
Ok(())
}
async fn run_library_tracks_save(args: LibraryMutateArgs) -> Result<()> {
library_mutate_tracks(args, true).await
}
async fn run_library_tracks_unsave(args: LibraryMutateArgs) -> Result<()> {
library_mutate_tracks(args, false).await
}
async fn library_mutate_tracks(args: LibraryMutateArgs, save: bool) -> Result<()> {
let permissions = perms::load_effective()?;
permissions.check_time(SpotifyFunction::LibraryWrite)?;
for u in &args.items {
permissions.check_target(SpotifyFunction::LibraryWrite, u)?;
}
let ids = items_to_ids(&args.items, "track");
let http = http_for()?;
if save {
http.save_tracks(&ids).await?;
} else {
http.unsave_tracks(&ids).await?;
}
let verb = if save { "saved" } else { "unsaved" };
if args.json {
let out = serde_json::json!({ verb: ids });
println!("{}", serde_json::to_string_pretty(&out).unwrap());
} else {
println!("{verb} {} track(s)", ids.len());
}
Ok(())
}
async fn run_library_albums_list(args: LibraryListArgs) -> Result<()> {
let permissions = perms::load_effective()?;
permissions.check_time(SpotifyFunction::LibraryRead)?;
let http = http_for()?;
let items = http.list_saved_albums(args.limit).await?;
let filtered: Vec<&SavedAlbum> = items
.iter()
.filter(|s| {
s.album
.uri
.as_deref()
.map(|u| {
permissions
.check_target(SpotifyFunction::LibraryRead, u)
.is_ok()
})
.unwrap_or(true)
})
.collect();
if args.json {
println!("{}", serde_json::to_string_pretty(&filtered).unwrap());
return Ok(());
}
if filtered.is_empty() {
println!("No saved albums (or all filtered by permissions).");
return Ok(());
}
for s in &filtered {
let artists = s
.album
.artists
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ");
let date = s.album.release_date.as_deref().unwrap_or("?");
println!(
" {:25} {} — {} ({date})",
s.album.id, s.album.name, artists
);
}
Ok(())
}
async fn run_library_albums_save(args: LibraryMutateArgs) -> Result<()> {
library_mutate_albums(args, true).await
}
async fn run_library_albums_unsave(args: LibraryMutateArgs) -> Result<()> {
library_mutate_albums(args, false).await
}
async fn library_mutate_albums(args: LibraryMutateArgs, save: bool) -> Result<()> {
let permissions = perms::load_effective()?;
permissions.check_time(SpotifyFunction::LibraryWrite)?;
for u in &args.items {
permissions.check_target(SpotifyFunction::LibraryWrite, u)?;
}
let ids = items_to_ids(&args.items, "album");
let http = http_for()?;
if save {
http.save_albums(&ids).await?;
} else {
http.unsave_albums(&ids).await?;
}
let verb = if save { "saved" } else { "unsaved" };
if args.json {
let out = serde_json::json!({ verb: ids });
println!("{}", serde_json::to_string_pretty(&out).unwrap());
} else {
println!("{verb} {} album(s)", ids.len());
}
Ok(())
}
fn run_permissions(args: PermissionsArgs) -> Result<()> {
match args.action {
None => run_permissions_show(PermissionsShowArgs { json: args.json }),
Some(PermissionsAction::Show(a)) => run_permissions_show(a),
Some(PermissionsAction::Path(a)) => run_permissions_path(a),
Some(PermissionsAction::Init(a)) => run_permissions_init(a),
Some(PermissionsAction::Check(a)) => run_permissions_check(a),
Some(PermissionsAction::Staging(a)) => {
crate::cli::permissions::run::<perms::PermissionsService>(a)
}
}
}
#[derive(Debug, Serialize)]
struct PermissionsScopeOut {
path: String,
present: bool,
}
#[derive(Debug, Serialize)]
struct PermissionsShowOut {
command: &'static str,
global: PermissionsScopeOut,
local: PermissionsScopeOut,
}
fn run_permissions_show(args: PermissionsShowArgs) -> Result<()> {
let global_path = perms::global_path()?;
let local_path = perms::local_path_current()?;
let _ = perms::load_effective()?;
if args.json {
let out = PermissionsShowOut {
command: "spotify.permissions.show",
global: PermissionsScopeOut {
path: global_path.display().to_string(),
present: global_path.exists(),
},
local: PermissionsScopeOut {
path: local_path.display().to_string(),
present: local_path.exists(),
},
};
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return Ok(());
}
println!("Spotify permissions");
print_scope_block("global", &global_path);
print_scope_block("local", &local_path);
Ok(())
}
fn print_scope_block(label: &str, path: &Path) {
println!();
println!(" [{label}] {}", path.display());
if !path.exists() {
println!(" (no file at this scope)");
return;
}
match std::fs::read_to_string(path) {
Ok(body) => {
for line in body.lines() {
println!(" {line}");
}
}
Err(e) => println!(" (could not read: {e})"),
}
}
fn run_permissions_path(args: PermissionsPathArgs) -> Result<()> {
let global_path = perms::global_path()?;
let local_path = perms::local_path_current()?;
if args.json {
let out = serde_json::json!({
"command": "spotify.permissions.path",
"global": global_path.display().to_string(),
"local": local_path.display().to_string(),
});
println!("{}", serde_json::to_string_pretty(&out).unwrap());
} else {
println!("{}", global_path.display());
println!("{}", local_path.display());
}
Ok(())
}
#[derive(Debug, Serialize)]
struct PermissionsInitOutput {
command: &'static str,
scope: &'static str,
path: String,
written: bool,
}
fn run_permissions_init(args: PermissionsInitArgs) -> Result<()> {
let (path, scope) = if args.local {
(perms::local_path_current()?, "local")
} else {
(perms::global_path()?, "global")
};
if path.exists() && !args.force {
return Err(ZadError::Invalid(format!(
"permissions file already exists at {}. Pass --force to overwrite.",
path.display()
)));
}
let template = perms::starter_template();
let key = zad::permissions::signing::load_or_create_from_keychain()?;
zad::permissions::signing::write_public_key_cache(&key)?;
perms::save_file(&path, &template, &key)?;
if args.json {
let out = PermissionsInitOutput {
command: "spotify.permissions.init",
scope,
path: path.display().to_string(),
written: true,
};
println!("{}", serde_json::to_string_pretty(&out).unwrap());
} else {
println!("Wrote starter permissions ({scope}): {}", path.display());
println!("Signed with key {}.", key.fingerprint());
println!("Review it; the defaults deny `*release*`/`*official*` playlists.");
}
Ok(())
}
fn run_permissions_check(args: PermissionsCheckArgs) -> Result<()> {
let f = parse_function(&args.function)?;
let permissions = perms::load_effective()?;
permissions.check_time(f)?;
if let Some(t) = &args.target {
permissions.check_target(f, t)?;
}
if let Some(b) = &args.body {
permissions.check_body(f, b)?;
}
if args.json {
let out = serde_json::json!({
"command": "spotify.permissions.check",
"function": args.function,
"ok": true,
});
println!("{}", serde_json::to_string_pretty(&out).unwrap());
} else {
println!("✓ would be allowed by current spotify permissions");
}
Ok(())
}
fn parse_function(name: &str) -> Result<SpotifyFunction> {
Ok(match name {
"search" => SpotifyFunction::Search,
"playlists_read" => SpotifyFunction::PlaylistsRead,
"playlists_write" => SpotifyFunction::PlaylistsWrite,
"library_read" => SpotifyFunction::LibraryRead,
"library_write" => SpotifyFunction::LibraryWrite,
other => {
return Err(ZadError::Invalid(format!(
"unknown function `{other}`; expected one of: search, playlists_read, \
playlists_write, library_read, library_write"
)));
}
})
}
pub(crate) fn effective_config()
-> Result<(SpotifyServiceCfg, &'static str, Scope<'static>, PathBuf)> {
let slug = config::path::project_slug()?;
let local_path = config::path::project_service_config_path_for(&slug, "spotify")?;
let global_path = config::path::global_service_config_path("spotify")?;
let project_cfg = config::load()?;
if !project_cfg.has_service("spotify") {
return Err(ZadError::Invalid(format!(
"spotify is not enabled for this project ({}). \
Run `zad service enable spotify` first.",
config::path::project_config_path()?.display()
)));
}
if let Some(cfg) = config::load_flat::<SpotifyServiceCfg>(&local_path)? {
let slug_leaked = leak(slug);
return Ok((cfg, "local", Scope::Project(slug_leaked), local_path));
}
if let Some(cfg) = config::load_flat::<SpotifyServiceCfg>(&global_path)? {
return Ok((cfg, "global", Scope::Global, global_path));
}
Err(ZadError::Invalid(format!(
"no spotify credentials found.\n looked in:\n {}\n {}\n \
Run `zad service create spotify`.",
local_path.display(),
global_path.display()
)))
}
fn http_for() -> Result<SpotifyHttp> {
let (cfg, _label, scope, path) = effective_config()?;
let client_id = secrets::load(&secrets::account("spotify", "client-id", scope.clone()))?
.ok_or(ZadError::Service {
name: "spotify",
message: "client-id missing from keychain; re-run `zad service create spotify`".into(),
})?;
let refresh_token = secrets::load(&secrets::account("spotify", "refresh", scope))?.ok_or(
ZadError::Service {
name: "spotify",
message: "refresh token missing from keychain; re-run `zad service create spotify`"
.into(),
},
)?;
let scope_set: BTreeSet<String> = cfg.scopes.iter().cloned().collect();
Ok(SpotifyHttp::new(client_id, refresh_token, scope_set, path))
}
fn playlist_target(flag: Option<&str>, default: Option<&str>) -> Result<String> {
if let Some(v) = flag {
return Ok(v.to_string());
}
if let Some(v) = default {
return Ok(v.to_string());
}
Err(ZadError::MissingRequired(
"--playlist (or set `default_playlist` in the spotify config)",
))
}
fn strip_playlist_uri(s: &str) -> String {
s.strip_prefix("spotify:playlist:")
.or_else(|| s.strip_prefix("spotify:track:"))
.or_else(|| s.strip_prefix("spotify:album:"))
.unwrap_or(s)
.to_string()
}
fn normalize_track_uris(items: &[String]) -> Vec<String> {
items
.iter()
.map(|s| {
if s.starts_with("spotify:") {
s.clone()
} else {
format!("spotify:track:{s}")
}
})
.collect()
}
fn items_to_ids(items: &[String], kind: &str) -> Vec<String> {
let prefix = format!("spotify:{kind}:");
items
.iter()
.map(|s| s.strip_prefix(&prefix).unwrap_or(s).to_string())
.collect()
}