exportify-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("exportify-cli")
5
+ except PackageNotFoundError: # running from source without install
6
+ __version__ = "1.0.0+dev"
exportify_cli/cli.py ADDED
@@ -0,0 +1,444 @@
1
+ """Command line interface."""
2
+ # In case a human reads this: AI (Fable) rewrote the program, then I read
3
+ # and understood everything and made my own handwritten changes.
4
+
5
+ import logging
6
+ import re
7
+ import shutil
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import click
12
+ import spotipy
13
+ from click_option_group import OptionGroup, optgroup
14
+ from pathvalidate import sanitize_filename
15
+ from spotipy.oauth2 import SpotifyOauthError, SpotifyPKCE
16
+ from tabulate import tabulate
17
+
18
+ from . import __version__
19
+ from .config import (
20
+ default_config_path,
21
+ load_config,
22
+ opt,
23
+ token_cache_path,
24
+ )
25
+ from .export import ALL_HEADERS, FIELD_ALIASES, SpotifyExporter, parse_fields
26
+
27
+ logging.basicConfig(
28
+ level=logging.WARNING,
29
+ format="%(asctime)s | %(levelname)s | %(message)s",
30
+ handlers=[logging.StreamHandler(sys.stderr)],
31
+ )
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # ex: "37i9dQZF1DXcBWIGoYBM5M"
35
+ PLAYLIST_ID_LENGTH = 22
36
+
37
+ VALID_FORMATS = {"csv", "json"}
38
+
39
+
40
+ def clean_playlist_input(playlists: list[str]) -> list[str]:
41
+ """Convert playlist URLs or URIs to bare IDs."""
42
+ cleaned = []
43
+ for p in playlists:
44
+ p = re.sub(r"^.*playlists?/([a-zA-Z0-9]{22}).*$", r"\1", p)
45
+ cleaned.append(p.replace("spotify:playlist:", ""))
46
+ return cleaned
47
+
48
+
49
+ def clean_user_input(users: list[str]) -> list[str]:
50
+ """Convert user URLs or URIs to bare IDs."""
51
+ cleaned = []
52
+ for u in users:
53
+ u = re.sub(r"^.*users?/([a-zA-Z0-9]+).*$", r"\1", u)
54
+ cleaned.append(u.replace("spotify:user:", ""))
55
+ return cleaned
56
+
57
+
58
+ def resolve_formats(format_param: tuple[str, ...], cfg: dict) -> list[str]:
59
+ """Resolve output formats, --format > config > default."""
60
+ if format_param:
61
+ return list(dict.fromkeys(format_param))
62
+ formats = [f.lower() for f in opt(cfg, "format")]
63
+ invalid = [f for f in formats if f not in VALID_FORMATS]
64
+ if invalid or not formats:
65
+ click.echo(
66
+ f"Error: invalid format(s) in config: {', '.join(invalid) or '(empty)'}. "
67
+ f"Valid formats: {', '.join(sorted(VALID_FORMATS))}.",
68
+ err=True,
69
+ )
70
+ sys.exit(1)
71
+ return list(dict.fromkeys(formats))
72
+
73
+
74
+ def resolve_sort_key(sort_key: str) -> str:
75
+ """Match sort parameter with key or alias."""
76
+
77
+ def normalize(s: str) -> str:
78
+ return re.sub(r"[\s_()]", "", s.lower())
79
+
80
+ for key in ALL_HEADERS:
81
+ if normalize(key) == normalize(sort_key):
82
+ return key
83
+ for key in FIELD_ALIASES:
84
+ if normalize(key) == normalize(sort_key):
85
+ return FIELD_ALIASES[key]
86
+ click.echo(
87
+ f"Error: sort key '{sort_key}' not found. Available keys: {ALL_HEADERS}.",
88
+ err=True,
89
+ )
90
+ sys.exit(1)
91
+
92
+
93
+ def init_spotify_client(cfg: dict, cache_path: Path) -> spotipy.Spotify:
94
+ """Initialize Spotify client with PKCE manager."""
95
+ creds = cfg["spotify"]
96
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
97
+ auth = SpotifyPKCE(
98
+ client_id=creds["client_id"],
99
+ redirect_uri=creds["redirect_uri"],
100
+ cache_path=str(cache_path),
101
+ scope=["user-library-read", "playlist-read-private"],
102
+ )
103
+ try:
104
+ auth.get_access_token(check_cache=True)
105
+ except SpotifyOauthError as err:
106
+ logger.error(f"Spotify authentication failed: {err}")
107
+ sys.exit(1)
108
+ return spotipy.Spotify(auth_manager=auth, retries=10)
109
+
110
+
111
+ def list_playlists(playlists: list[dict]) -> None:
112
+ data = [[p["name"], p["id"], p["tracks"]["total"]] for p in playlists]
113
+ # shutil (unlike os.get_terminal_size) has a fallback when not a tty
114
+ terminal_width = shutil.get_terminal_size().columns
115
+ click.echo(
116
+ tabulate(
117
+ data,
118
+ headers=["Name", "ID", "Tracks"],
119
+ tablefmt="simple",
120
+ # 34 is the width of ID and Tracks columns + padding
121
+ maxcolwidths=[terminal_width - 34, None, None],
122
+ )
123
+ )
124
+
125
+
126
+ def match_targets(
127
+ client: spotipy.Spotify, fetched_playlists: list[dict], terms: list[str]
128
+ ) -> tuple[list[dict], list[str]]:
129
+ """Match requested names/IDs against the user's playlists.
130
+
131
+ Exact name/ID match first, then unique prefix match, then a direct
132
+ fetch for anything that looks like a playlist ID. Returns the matched
133
+ playlists and the terms that couldn't be resolved.
134
+ """
135
+ targets: list[dict] = []
136
+ unmatched: list[str] = []
137
+ for term in terms:
138
+ exact = [p for p in fetched_playlists if term in (p["name"], p["id"])]
139
+ if exact:
140
+ targets.extend(exact)
141
+ continue
142
+
143
+ matches = [
144
+ p for p in fetched_playlists if p["name"].lower().startswith(term.lower())
145
+ ]
146
+
147
+ if len(matches) == 1:
148
+ targets.append(matches[0])
149
+
150
+ elif len(matches) > 1:
151
+ # Considered adding an extra case sensitive match here but decided against it
152
+ click.echo(
153
+ f"Ambiguous prefix '{term}': matches "
154
+ f"{', '.join(p['name'] for p in matches)}. Skipping.",
155
+ )
156
+ unmatched.append(term)
157
+
158
+ elif term.isalnum() and len(term) == PLAYLIST_ID_LENGTH:
159
+ # May be a playlist the user hasn't saved
160
+ try:
161
+ pl = client.playlist(term)
162
+ if pl:
163
+ targets.append(pl)
164
+ continue
165
+ except spotipy.SpotifyException as e:
166
+ logger.warning(f"Failed to fetch playlist {term}: {e}")
167
+ unmatched.append(term)
168
+
169
+ else:
170
+ unmatched.append(term)
171
+
172
+ # Deduplicate, preserving order
173
+ targets = list({p["id"]: p for p in targets}.values())
174
+ return targets, unmatched
175
+
176
+
177
+ def fetch_user_targets(
178
+ client: spotipy.Spotify, exporter: SpotifyExporter, user_ids: list[str]
179
+ ) -> tuple[list[dict], list[str]]:
180
+ """Fetch public playlists for each user ID; returns (targets, failed_ids)."""
181
+ users_targets: list[dict] = []
182
+ failed: list[str] = []
183
+ for uid in user_ids:
184
+ try:
185
+ items = exporter.fetch_paginated(
186
+ client.user_playlists,
187
+ "items",
188
+ uid,
189
+ desc=f"Playlists of {uid}",
190
+ show_bar=False,
191
+ )
192
+ user_data = client.user(uid)
193
+ users_targets.append(
194
+ {
195
+ "name": user_data.get("display_name") or uid,
196
+ "uid": uid,
197
+ "targets": [t for t in items if t.get("uri")],
198
+ }
199
+ )
200
+ except spotipy.SpotifyException as e:
201
+ logger.warning(f"Failed to fetch playlists for user {uid}: {e}")
202
+ failed.append(uid)
203
+ return users_targets, failed
204
+
205
+
206
+ class CustomCommand(click.Command):
207
+ def format_usage(self, ctx, formatter) -> None:
208
+ formatter.write_text(
209
+ "Usage: exportify-cli (-a | -p NAME|ID|URL|URI [-p ...] "
210
+ "| -u ID|URL|URI | -l | --logout) [OPTIONS]\n",
211
+ )
212
+
213
+
214
+ @click.command(cls=CustomCommand)
215
+ @optgroup.group(cls=OptionGroup)
216
+ @optgroup.option("-a", "--all", "export_all", is_flag=True, help="Export all playlists")
217
+ @optgroup.option(
218
+ "-p",
219
+ "--playlist",
220
+ "playlist",
221
+ multiple=True,
222
+ metavar="NAME|ID|URL|URI",
223
+ help="Export a Spotify playlist given name, ID, URL, or URI; repeatable.",
224
+ )
225
+ @optgroup.option(
226
+ "-u",
227
+ "--user",
228
+ "user",
229
+ multiple=True,
230
+ metavar="ID|URL|URI",
231
+ help="Export all public playlists of a Spotify user given ID, URL, or URI; repeatable.",
232
+ )
233
+ @optgroup.option(
234
+ "-l", "--list", "list_only", is_flag=True, help="List available playlists."
235
+ )
236
+ @optgroup.option(
237
+ "--logout",
238
+ "logout",
239
+ is_flag=True,
240
+ help="Delete cached Spotify auth token and exit.",
241
+ )
242
+ @optgroup.option(
243
+ "-c",
244
+ "--config",
245
+ "config",
246
+ default=None,
247
+ type=click.Path(),
248
+ help="Path to configuration file (default: ./config.toml or "
249
+ "$XDG_CONFIG_HOME/exportify-cli/config.toml).",
250
+ )
251
+ @optgroup.option(
252
+ "-o",
253
+ "--output",
254
+ "output_param",
255
+ default=None,
256
+ type=click.Path(),
257
+ help="Directory to save exported files (default is ./playlists); '-' writes to stdout.",
258
+ )
259
+ @optgroup.option(
260
+ "-f",
261
+ "--format",
262
+ "format_param",
263
+ multiple=True,
264
+ type=click.Choice(sorted(VALID_FORMATS)),
265
+ help="Output file format (defaults to 'csv'); repeatable.",
266
+ )
267
+ @optgroup.option(
268
+ "--uris",
269
+ "uris_flag",
270
+ default=None,
271
+ is_flag=True,
272
+ help="(Deprecated: add the URI fields to --fields or the config instead.)",
273
+ )
274
+ @optgroup.option(
275
+ "--external-ids",
276
+ "external_ids_flag",
277
+ default=None,
278
+ is_flag=True,
279
+ help="(Deprecated: add 'Track ISRC'/'Album UPC' to --fields or the config instead.)",
280
+ )
281
+ @optgroup.option(
282
+ "--no-bar", "no_bar_flag", default=None, is_flag=True, help="Hide progress bar."
283
+ )
284
+ @optgroup.option(
285
+ "-s",
286
+ "--sort-key",
287
+ "sort_key",
288
+ default=None,
289
+ help="Key to sort tracks by (default is 'spotify_default').",
290
+ )
291
+ @optgroup.option(
292
+ "--reverse",
293
+ "reverse_order",
294
+ default=None,
295
+ is_flag=True,
296
+ help="Reverse the sort order.",
297
+ )
298
+ @optgroup.option(
299
+ "--fields",
300
+ "fields_param",
301
+ default=None,
302
+ help="Comma-separated list of fields to include.",
303
+ )
304
+ @click.help_option("-h", "--help")
305
+ @click.version_option(
306
+ __version__,
307
+ "-v",
308
+ "--version",
309
+ prog_name="exportify-cli",
310
+ message="%(prog)s v%(version)s",
311
+ )
312
+ def main(
313
+ export_all: bool,
314
+ playlist: tuple[str, ...],
315
+ user: tuple[str, ...],
316
+ list_only: bool,
317
+ logout: bool,
318
+ config: str | None,
319
+ output_param: str | None,
320
+ format_param: tuple[str, ...],
321
+ uris_flag: bool | None,
322
+ external_ids_flag: bool | None,
323
+ fields_param: str | None,
324
+ no_bar_flag: bool | None,
325
+ sort_key: str | None,
326
+ reverse_order: bool | None,
327
+ ) -> None:
328
+ """Export Spotify playlists to CSV or JSON."""
329
+
330
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore
331
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore
332
+
333
+ # --- Config ---
334
+ if config:
335
+ cfg_path = Path(config).expanduser()
336
+ if cfg_path.is_dir():
337
+ cfg_path = cfg_path / "config.toml"
338
+ else:
339
+ cfg_path = default_config_path()
340
+ cfg = load_config(cfg_path, explicit=config is not None)
341
+ cache_path = token_cache_path(cfg_path)
342
+
343
+ if logout:
344
+ if cache_path.exists():
345
+ cache_path.unlink()
346
+ click.echo(f"Logged out successfully. Removed token cache '{cache_path}'.")
347
+ else:
348
+ click.echo(f"No token cache file '{cache_path}' was found.")
349
+ sys.exit(0)
350
+
351
+ # --- Resolve options: CLI beats config, config beats defaults ---
352
+ file_formats = resolve_formats(format_param, cfg)
353
+ output = output_param if output_param is not None else opt(cfg, "output")
354
+ include_uris = bool(uris_flag)
355
+ external_ids = bool(external_ids_flag)
356
+ if include_uris or external_ids:
357
+ logger.warning(
358
+ "--uris and --external-ids are deprecated; use --fields or the config's 'fields' instead."
359
+ )
360
+ with_bar = not (no_bar_flag if no_bar_flag is not None else opt(cfg, "no_bar"))
361
+ actual_key = resolve_sort_key(
362
+ sort_key if sort_key is not None else opt(cfg, "sort_key")
363
+ )
364
+ reverse = reverse_order if reverse_order is not None else opt(cfg, "reverse")
365
+ fields = parse_fields(
366
+ fields_param.split(",") if fields_param is not None else opt(cfg, "fields"),
367
+ include_uris,
368
+ external_ids,
369
+ )
370
+ if actual_key not in fields:
371
+ logger.warning(
372
+ f"Sort key '{actual_key}' is not in the exported fields; sort is ignored."
373
+ )
374
+
375
+ playlists = list(playlist) or opt(cfg, "playlists")
376
+ users = list(user) or opt(cfg, "users")
377
+
378
+ if not (export_all or playlists or users or list_only):
379
+ click.echo(main.get_help(ctx=click.get_current_context()))
380
+ sys.exit(1)
381
+
382
+ # --- Spotify ---
383
+ client = init_spotify_client(cfg, cache_path)
384
+ exporter = SpotifyExporter(
385
+ spotify_client=client,
386
+ file_formats=file_formats,
387
+ fields=fields,
388
+ with_bar=with_bar,
389
+ sort_key=actual_key,
390
+ reverse_order=reverse,
391
+ )
392
+ fetched_playlists = exporter.get_playlists()
393
+
394
+ if list_only:
395
+ list_playlists(fetched_playlists)
396
+ sys.exit(0)
397
+
398
+ # --- Determine targets ---
399
+ if export_all:
400
+ targets, unmatched = fetched_playlists, []
401
+ else:
402
+ targets, unmatched = match_targets(
403
+ client, fetched_playlists, clean_playlist_input(playlists)
404
+ )
405
+ users_targets, failed_users = fetch_user_targets(
406
+ client, exporter, clean_user_input(users)
407
+ )
408
+
409
+ if not (targets or users_targets):
410
+ click.echo("No matching playlists found.")
411
+ sys.exit(1)
412
+
413
+ # --- Export ---
414
+ to_stdout = output == "-"
415
+ if to_stdout and len(file_formats) > 1:
416
+ click.echo("Error: -o - requires a single format (use -f).", err=True)
417
+ sys.exit(1)
418
+ out_dir = None if to_stdout else Path(output)
419
+ for pl in targets:
420
+ exporter.export_playlist(pl, out_dir)
421
+ for ut in users_targets:
422
+ user_out_dir = (
423
+ out_dir / sanitize_filename(f"{ut['name']} [{ut['uid']}]")
424
+ if out_dir is not None
425
+ else None
426
+ )
427
+ for pl in ut["targets"]:
428
+ exporter.export_playlist(pl, user_out_dir)
429
+
430
+ if exporter.exported_playlists > 1:
431
+ click.echo(
432
+ f"Successfully exported {exporter.exported_tracks} tracks "
433
+ f"from {exporter.exported_playlists} playlists.",
434
+ err=to_stdout,
435
+ )
436
+ if unmatched or failed_users:
437
+ skipped = [*unmatched, *failed_users]
438
+ click.echo(f"Skipped (not found or failed): {', '.join(skipped)}", err=True)
439
+ sys.exit(2)
440
+ sys.exit(0)
441
+
442
+
443
+ if __name__ == "__main__":
444
+ main()
@@ -0,0 +1,240 @@
1
+ """Configuration: file locations, loading, validation and first-run creation.
2
+
3
+ Config lives in TOML (config.toml). Legacy config.cfg files are migrated
4
+ automatically. The file is never rewritten by the program after creation;
5
+ missing options simply fall back to defaults at read time.
6
+ """
7
+
8
+ import configparser
9
+ import logging
10
+ import os
11
+ import sys
12
+ import tomllib
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import click
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ APP_NAME = "exportify-cli"
21
+
22
+ # Read-time defaults for the [exportify-cli] table (nothing is written back).
23
+ CLI_DEFAULTS: dict[str, Any] = {
24
+ "format": ["csv"],
25
+ "output": "./playlists",
26
+ "playlists": [],
27
+ "users": [],
28
+ "no_bar": False,
29
+ "sort_key": "spotify_default",
30
+ "reverse": False,
31
+ "fields": [],
32
+ }
33
+
34
+ DEFAULT_FIELDS = [
35
+ "Position",
36
+ "Track Name",
37
+ "Album Name",
38
+ "Artist Name(s)",
39
+ "Release Date",
40
+ "Duration_ms",
41
+ "Popularity",
42
+ "Added By",
43
+ "Added At",
44
+ "Record Label",
45
+ ]
46
+
47
+ CONFIG_TEMPLATE = """\
48
+ [spotify]
49
+ client_id = "{client_id}"
50
+ redirect_uri = "{redirect_uri}"
51
+
52
+ [exportify-cli]
53
+ # Output format(s): "csv", "json".
54
+ format = ["csv"]
55
+
56
+ # Directory where exports are written.
57
+ output = "./playlists"
58
+
59
+ # Playlists exported when no -a/-p/-u is given (names or IDs).
60
+ playlists = []
61
+
62
+ # Users whose public playlists to export (IDs).
63
+ users = []
64
+
65
+ # Hide the progress bar.
66
+ no_bar = false
67
+
68
+ # Key to sort tracks by ("spotify_default" keeps Spotify's order).
69
+ sort_key = "spotify_default"
70
+
71
+ # Reverse the sort order.
72
+ reverse = false
73
+
74
+ # Fields to export. All available fields:
75
+ # "Position", "Track URI", "Artist URI(s)", "Album URI", "Track Name",
76
+ # "Album Name", "Artist Name(s)", "Release Date", "Duration_ms", "Popularity",
77
+ # "Added By", "Added At", "Record Label", "Track ISRC", "Album UPC"
78
+ fields = {fields}
79
+ """
80
+
81
+
82
+ def app_base_dir() -> Path:
83
+ """Directory next to the running program (script or frozen binary)."""
84
+ if getattr(sys, "frozen", False): # PyInstaller --onefile
85
+ return Path(sys.executable).resolve().parent
86
+ return Path(__file__).resolve().parent
87
+
88
+
89
+ def xdg_config_dir() -> Path:
90
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
91
+ return base / APP_NAME
92
+
93
+
94
+ def xdg_cache_dir() -> Path:
95
+ base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
96
+ return base / APP_NAME
97
+
98
+
99
+ def default_config_path() -> Path:
100
+ """First existing of: ./config.toml, legacy locations, XDG path.
101
+ A config.cfg found where no config.toml exists gets migrated.
102
+ If nothing exists, the XDG path (where a new one is created)."""
103
+ dirs = [Path.cwd(), app_base_dir(), xdg_config_dir()]
104
+ for d in dirs:
105
+ if (d / "config.toml").is_file():
106
+ return d / "config.toml"
107
+ for d in dirs:
108
+ if (d / "config.cfg").is_file():
109
+ return migrate_legacy_config(d / "config.cfg")
110
+ return xdg_config_dir() / "config.toml"
111
+
112
+
113
+ def migrate_legacy_config(cfg_path: Path) -> Path:
114
+ """Convert an INI config.cfg to config.toml next to it. Keeps the .cfg."""
115
+ toml_path = cfg_path.with_name("config.toml")
116
+ old = configparser.ConfigParser(inline_comment_prefixes=("#", ";"))
117
+ old.read(cfg_path)
118
+
119
+ def _split(value: str) -> list[str]:
120
+ return [v.strip() for v in value.replace(",", "\n").splitlines() if v.strip()]
121
+
122
+ spotify = dict(old["spotify"]) if old.has_section("spotify") else {}
123
+ cli = dict(old["exportify-cli"]) if old.has_section("exportify-cli") else {}
124
+
125
+ fields = _split(cli.get("fields", "")) or DEFAULT_FIELDS
126
+ # old format option allowed space *or* comma separation
127
+ formats = cli.get("format", "csv").replace(",", " ").split() or ["csv"]
128
+ lines = [
129
+ "[spotify]",
130
+ f'client_id = "{spotify.get("client_id", "")}"',
131
+ f'redirect_uri = "{spotify.get("redirect_uri", "")}"',
132
+ "",
133
+ "[exportify-cli]",
134
+ f"format = {formats!r}".replace("'", '"'),
135
+ f'output = "{cli.get("output", "./playlists")}"',
136
+ f"playlists = {_split(cli.get('playlists', ''))!r}".replace("'", '"'),
137
+ f"users = {_split(cli.get('users', ''))!r}".replace("'", '"'),
138
+ f"no_bar = {cli.get('no_bar', 'false').lower()}",
139
+ f'sort_key = "{cli.get("sort_key", "spotify_default")}"',
140
+ f"reverse = {cli.get('reverse', 'false').lower()}",
141
+ f"fields = {fields!r}".replace("'", '"'),
142
+ "",
143
+ ]
144
+ toml_path.write_text("\n".join(lines), encoding="utf-8")
145
+ click.echo(f"Migrated legacy config '{cfg_path}' to '{toml_path}'.")
146
+ return toml_path
147
+
148
+
149
+ def validate_config(config: dict) -> bool:
150
+ """Spotify table exists, has the required keys and a sane redirect URI."""
151
+ spotify = config.get("spotify")
152
+ if not isinstance(spotify, dict):
153
+ logger.error("Configuration missing [spotify] section.")
154
+ return False
155
+ missing = [
156
+ k for k in ("client_id", "redirect_uri") if not str(spotify.get(k, "")).strip()
157
+ ]
158
+ if missing:
159
+ logger.error(
160
+ f"Missing or empty keys in [spotify] section: {', '.join(missing)}"
161
+ )
162
+ return False
163
+ if not str(spotify["redirect_uri"]).strip().startswith(("http://", "https://")):
164
+ logger.error(f"Invalid redirect URI: {spotify['redirect_uri']}.")
165
+ return False
166
+ return True
167
+
168
+
169
+ def create_config(config_path: Path) -> None:
170
+ """Interactive first-run wizard; writes a commented template."""
171
+ click.echo(
172
+ f'No valid config found. Creating "{config_path}".\n'
173
+ "Use a Client ID and Redirect URI that belong to the same Spotify app.\n"
174
+ )
175
+ client_id = click.prompt("Spotify Client ID", type=str).strip()
176
+ redirect_uri = click.prompt("Redirect URI", type=str).strip()
177
+ config_path.parent.mkdir(parents=True, exist_ok=True)
178
+ fields_toml = "[" + ", ".join(f'"{f}"' for f in DEFAULT_FIELDS) + "]"
179
+ config_path.write_text(
180
+ CONFIG_TEMPLATE.format(
181
+ client_id=client_id, redirect_uri=redirect_uri, fields=fields_toml
182
+ ),
183
+ encoding="utf-8",
184
+ )
185
+ logger.info(f"Wrote new config to {config_path}")
186
+
187
+
188
+ def read_toml(config_path: Path) -> dict:
189
+ try:
190
+ with config_path.open("rb") as f:
191
+ return tomllib.load(f)
192
+ except tomllib.TOMLDecodeError as e:
193
+ click.echo(f"Error: could not parse '{config_path}': {e}", err=True)
194
+ sys.exit(1)
195
+
196
+
197
+ def load_config(config_path: Path, explicit: bool) -> dict:
198
+ """Load and validate config; run the creation wizard if needed.
199
+
200
+ An explicitly passed path (-c) that doesn't exist is an error, not a
201
+ silent wizard in an unexpected location. An explicit .cfg gets migrated.
202
+ """
203
+ if explicit and config_path.suffix == ".cfg" and config_path.is_file():
204
+ config_path = migrate_legacy_config(config_path)
205
+
206
+ if config_path.is_file():
207
+ config = read_toml(config_path)
208
+ if validate_config(config):
209
+ return config
210
+ if explicit:
211
+ click.echo(f"Error: config file '{config_path}' is invalid.", err=True)
212
+ sys.exit(1)
213
+ elif explicit:
214
+ click.echo(f"Error: config file '{config_path}' does not exist.", err=True)
215
+ sys.exit(1)
216
+
217
+ create_config(config_path)
218
+ config = read_toml(config_path)
219
+ if not validate_config(config):
220
+ logger.error("Invalid Spotify configuration.")
221
+ sys.exit(1)
222
+ return config
223
+
224
+
225
+ def opt(config: dict, key: str) -> Any:
226
+ """Read an [exportify-cli] option, falling back to CLI_DEFAULTS.
227
+ String values for list options are coerced (comma-separated)."""
228
+ value = config.get("exportify-cli", {}).get(key, CLI_DEFAULTS[key])
229
+ if isinstance(CLI_DEFAULTS[key], list) and isinstance(value, str):
230
+ value = [v.strip() for v in value.split(",") if v.strip()]
231
+ return value
232
+
233
+
234
+ def token_cache_path(config_path: Path) -> Path:
235
+ """Spotipy token cache. Reuse a legacy `.cache` next to the config if
236
+ present (avoids forcing re-auth), otherwise use the XDG cache dir."""
237
+ legacy = config_path.parent / ".cache"
238
+ if legacy.is_file():
239
+ return legacy
240
+ return xdg_cache_dir() / "token.cache"
@@ -0,0 +1,377 @@
1
+ """Fetching from Spotify and writing playlist exports."""
2
+
3
+ import csv
4
+ import json
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ import click
11
+ import spotipy
12
+ from pathvalidate import sanitize_filename
13
+ from tqdm.auto import tqdm
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ DEFAULT_BAR_FORMAT = (
18
+ "{desc}{percentage:3.0f}%|{bar}| {n_fmt:>4}"
19
+ "/{total_fmt:>4} [{elapsed:>6}<{remaining:>6}]"
20
+ )
21
+
22
+ # Max length for playlist name in progress bar
23
+ DESC_LENGTH = 21
24
+
25
+ # All exportable fields, in output order
26
+ ALL_HEADERS = [
27
+ "Position",
28
+ "Track URI",
29
+ "Artist URI(s)",
30
+ "Album URI",
31
+ "Track Name",
32
+ "Album Name",
33
+ "Artist Name(s)",
34
+ "Release Date",
35
+ "Duration_ms",
36
+ "Popularity",
37
+ "Added By",
38
+ "Added At",
39
+ "Record Label",
40
+ "Track ISRC",
41
+ "Album UPC",
42
+ ]
43
+
44
+ FIELD_ALIASES = {
45
+ "position": "Position",
46
+ "default": "Position",
47
+ # In previous versions of exportify-cli, spotify_default was
48
+ # separate code-wise from Position, but it's the same thing
49
+ "spotify_default": "Position",
50
+ "track_uri": "Track URI",
51
+ "uri": "Track URI",
52
+ "artist_uris": "Artist URI(s)",
53
+ "artist_uri": "Artist URI(s)",
54
+ "album_uri": "Album URI",
55
+ "name": "Track Name",
56
+ "album": "Album Name",
57
+ "artist": "Artist Name(s)",
58
+ "artists": "Artist Name(s)",
59
+ "artists name": "Artist Name(s)",
60
+ "date": "Release Date",
61
+ "duration": "Duration_ms",
62
+ "popularity": "Popularity",
63
+ "added_by": "Added By",
64
+ "added_at": "Added At",
65
+ "label": "Record Label",
66
+ "isrc": "Track ISRC",
67
+ "upc": "Album UPC",
68
+ }
69
+
70
+ URI_HEADERS = ["Track URI", "Artist URI(s)", "Album URI"]
71
+ EXTERNAL_ID_HEADERS = ["Track ISRC", "Album UPC"]
72
+
73
+
74
+ def parse_fields(
75
+ fields_value: list[str], include_uris: bool, external_ids: bool
76
+ ) -> list[str]:
77
+ """Resolve export headers from a list of field names/aliases.
78
+
79
+ --uris / --external-ids add their headers on top; when no fields are
80
+ given, the default is every header minus the URI/external-id ones.
81
+ """
82
+ headers: list[str] = []
83
+ for field in (f.strip() for f in fields_value if f.strip()):
84
+ if field in ALL_HEADERS:
85
+ headers.append(field)
86
+ elif field.lower() in FIELD_ALIASES:
87
+ headers.append(FIELD_ALIASES[field.lower()])
88
+ else:
89
+ logger.warning(f"Unknown field: {field}")
90
+
91
+ if not headers:
92
+ excluded = URI_HEADERS + EXTERNAL_ID_HEADERS
93
+ headers = [h for h in ALL_HEADERS if h not in excluded]
94
+
95
+ if include_uris:
96
+ headers += [h for h in URI_HEADERS if h not in headers]
97
+ if external_ids:
98
+ headers += [h for h in EXTERNAL_ID_HEADERS if h not in headers]
99
+
100
+ return list(dict.fromkeys(headers))
101
+
102
+
103
+ def sort_value(value: Any) -> tuple:
104
+ """Sort key that handles None and compares numbers numerically."""
105
+ if value is None:
106
+ return (0, 0.0, "")
107
+ try:
108
+ return (1, float(value), "")
109
+ except (TypeError, ValueError):
110
+ return (2, 0.0, str(value).lower())
111
+
112
+
113
+ def _write_csv(stream, headers: list[str], data: list[dict]) -> None:
114
+ writer = csv.DictWriter(stream, fieldnames=headers)
115
+ writer.writeheader()
116
+ for row in data:
117
+ writer.writerow(
118
+ {k: ", ".join(v) if isinstance(v, list) else v for k, v in row.items()}
119
+ )
120
+
121
+
122
+ def write_stdout(headers: list[str], data: list[dict], file_format: str) -> None:
123
+ """Write a single export to stdout."""
124
+ if file_format == "csv":
125
+ _write_csv(sys.stdout, headers, data)
126
+ else:
127
+ json.dump(data, sys.stdout, ensure_ascii=False, indent=4)
128
+ sys.stdout.write("\n")
129
+
130
+
131
+ def write_file(
132
+ file_path: Path, headers: list[str], data: list[dict], file_formats: list[str]
133
+ ) -> None:
134
+ """Write list of dicts to CSV and/or JSON files."""
135
+ if "csv" in file_formats:
136
+ csv_path = file_path.with_name(file_path.name + ".csv")
137
+ with csv_path.open("w", newline="", encoding="utf-8") as csvfile:
138
+ _write_csv(csvfile, headers, data)
139
+ logger.info(f"Exported to {csv_path}")
140
+
141
+ if "json" in file_formats:
142
+ json_path = file_path.with_name(file_path.name + ".json")
143
+ with json_path.open("w", encoding="utf-8") as jsonfile:
144
+ json.dump(data, jsonfile, ensure_ascii=False, indent=4)
145
+ logger.info(f"Exported to {json_path}")
146
+
147
+
148
+ class SpotifyExporter:
149
+ """Exports Spotify playlists to files."""
150
+
151
+ def __init__(
152
+ self,
153
+ spotify_client: spotipy.Spotify,
154
+ file_formats: list[str],
155
+ fields: list[str],
156
+ with_bar: bool,
157
+ sort_key: str,
158
+ reverse_order: bool,
159
+ ) -> None:
160
+ self.spotify = spotify_client
161
+ self.file_formats = file_formats
162
+ self.fields = fields
163
+ self.with_bar = with_bar
164
+ self.sort_key = sort_key
165
+ self.reverse_order = reverse_order
166
+ self.exported_playlists = 0
167
+ self.exported_tracks = 0
168
+ self.album_cache: dict[str, dict] = {}
169
+
170
+ def _progress_bar(
171
+ self, total: int | None, desc: str, unit: str, show_bar: bool
172
+ ) -> tqdm | None:
173
+ if not (show_bar and self.with_bar):
174
+ return None
175
+ return tqdm(total=total, desc=desc, unit=unit, bar_format=DEFAULT_BAR_FORMAT)
176
+
177
+ def fetch_paginated(
178
+ self,
179
+ fetch_func: Callable,
180
+ key: str,
181
+ *args: Any,
182
+ desc: str | None = None,
183
+ show_bar: bool = True,
184
+ **kwargs: Any,
185
+ ) -> list[dict]:
186
+ """Fetch all pages from a paginated endpoint (playlist_tracks, etc.)."""
187
+ results = fetch_func(*args, **kwargs)
188
+ items: list[dict] = list(results.get(key, []))
189
+
190
+ pbar = self._progress_bar(
191
+ results.get("total"),
192
+ desc or results.get("name") or fetch_func.__name__, # type: ignore
193
+ "track",
194
+ show_bar,
195
+ )
196
+ if pbar:
197
+ pbar.update(len(items))
198
+
199
+ while results.get("next"):
200
+ results = self.spotify.next(results)
201
+ page_items = results.get(key, [])
202
+ items.extend(page_items)
203
+ if pbar:
204
+ pbar.update(len(page_items))
205
+
206
+ if pbar:
207
+ pbar.close()
208
+ return items
209
+
210
+ def fetch_albums(self, album_ids: list[str], already_cached: int = 0) -> list[dict]:
211
+ """Fetch album details in batches of 20; IDs that aren't albums are
212
+ retried as shows (podcasts saved in playlists)."""
213
+ items: list[dict] = []
214
+ total = len(album_ids) + already_cached
215
+ pbar = self._progress_bar(total, "Fetching album details: ", "album", True)
216
+ if pbar and already_cached:
217
+ pbar.update(already_cached)
218
+
219
+ for start in range(0, len(album_ids), 20):
220
+ batch = album_ids[start : start + 20]
221
+ page_items = [a for a in self.spotify.albums(batch).get("albums", []) if a]
222
+
223
+ fetched_ids = {a["id"] for a in page_items if a.get("id")}
224
+ for show_id in set(batch) - fetched_ids:
225
+ try:
226
+ show = self.spotify.show(show_id)
227
+ show["label"] = show.get("publisher")
228
+ page_items.append(show)
229
+ except spotipy.SpotifyException as e:
230
+ logger.warning(f"Failed to fetch show for ID {show_id}: {e}")
231
+
232
+ items.extend(page_items)
233
+ if pbar:
234
+ pbar.update(len(page_items))
235
+
236
+ if pbar:
237
+ pbar.close()
238
+ return items
239
+
240
+ def _fix_episode(self, item: dict) -> None:
241
+ """Fill in release date and artist names for podcast episodes."""
242
+ track = item.get("track")
243
+ if not track or track.get("type") != "episode":
244
+ return
245
+ episode = self.spotify.episode(track.get("id"))
246
+ track["release_date"] = episode.get("release_date")
247
+ for artist in track.get("artists", []):
248
+ artist["name"] = artist.get("type")
249
+
250
+ def get_playlists(self) -> list[dict]:
251
+ """Retrieve all user playlists plus liked songs."""
252
+ items = self.fetch_paginated(
253
+ self.spotify.current_user_playlists,
254
+ "items",
255
+ desc="Playlists",
256
+ show_bar=False,
257
+ )
258
+ liked_total = self.spotify.current_user_saved_tracks(limit=1)["total"]
259
+ liked = {
260
+ "name": "Liked Songs",
261
+ "id": "liked_songs",
262
+ "tracks": {"total": liked_total},
263
+ }
264
+ return [liked, *items]
265
+
266
+ def _fetch_tracks(self, playlist: dict) -> list[dict]:
267
+ name, pid = playlist["name"], playlist["id"]
268
+ desc = (
269
+ name[: DESC_LENGTH - 2] + "...: "
270
+ if len(name) > DESC_LENGTH - 2
271
+ else f"{name}: ".ljust(DESC_LENGTH + 3)
272
+ )
273
+ if pid == "liked_songs":
274
+ items = self.fetch_paginated(
275
+ self.spotify.current_user_saved_tracks, "items", desc=desc
276
+ )
277
+ else:
278
+ items = self.fetch_paginated(
279
+ self.spotify.playlist_tracks, "items", pid, desc=desc
280
+ )
281
+ for item in items:
282
+ self._fix_episode(item)
283
+ return items
284
+
285
+ def _album_lookup(self, items: list[dict]) -> dict[str, dict]:
286
+ """Batch-fetch album details for all tracks, using the cache."""
287
+ album_ids = {
288
+ i["track"]["album"]["id"]
289
+ for i in items
290
+ if i.get("track")
291
+ and i["track"].get("album")
292
+ and i["track"]["album"].get("id")
293
+ }
294
+ ids_to_fetch = [aid for aid in album_ids if aid not in self.album_cache]
295
+ if ids_to_fetch:
296
+ for alb in self.fetch_albums(
297
+ ids_to_fetch, already_cached=len(album_ids) - len(ids_to_fetch)
298
+ ):
299
+ if alb.get("id"):
300
+ self.album_cache[alb["id"]] = {
301
+ "id": alb.get("id"),
302
+ "uri": alb.get("uri"),
303
+ "name": alb.get("name"),
304
+ "release_date": alb.get("release_date"),
305
+ "label": alb.get("label"),
306
+ "external_ids": alb.get("external_ids", {}),
307
+ }
308
+ return {
309
+ aid: self.album_cache[aid] for aid in album_ids if aid in self.album_cache
310
+ }
311
+
312
+ def _build_record(self, position: int, item: dict, albums: dict) -> dict:
313
+ # Playlists can contain items with 'track': None (seen in the wild;
314
+ # no idea how it got generated). We keep them as mostly-empty rows
315
+ # instead of dropping them: they carry no track info, but one could
316
+ # hypothetically do detective work out of 'added_at'/'added_by' to
317
+ # find what they originally were.
318
+ track = item.get("track") or {}
319
+ album = albums.get(track.get("album", {}).get("id"), {})
320
+ artists = [a.get("name") for a in track.get("artists", []) if a.get("name")]
321
+ artist_uris = [a.get("uri") for a in track.get("artists", []) if a.get("uri")]
322
+ record = dict(
323
+ zip(
324
+ ALL_HEADERS,
325
+ [
326
+ position,
327
+ track.get("uri"),
328
+ artist_uris,
329
+ album.get("uri"),
330
+ track.get("name"),
331
+ album.get("name"),
332
+ artists,
333
+ album.get("release_date") or track.get("release_date"),
334
+ track.get("duration_ms"),
335
+ track.get("popularity"),
336
+ item.get("added_by", {}).get("id"),
337
+ item.get("added_at"),
338
+ album.get("label"),
339
+ track.get("external_ids", {}).get("isrc"),
340
+ album.get("external_ids", {}).get("upc"),
341
+ ],
342
+ )
343
+ )
344
+ return {k: v for k, v in record.items() if k in self.fields}
345
+
346
+ def export_playlist(self, playlist: dict, output_dir: Path | None) -> None:
347
+ """Export a single playlist to the configured formats.
348
+
349
+ A None output_dir writes to stdout (status messages go to stderr).
350
+ """
351
+ name, pid = playlist["name"], playlist["id"]
352
+ to_stdout = output_dir is None
353
+ if not to_stdout:
354
+ output_dir.mkdir(parents=True, exist_ok=True)
355
+ filepath = output_dir / sanitize_filename(f"{name} [{pid}]")
356
+
357
+ items = self._fetch_tracks(playlist)
358
+ albums = self._album_lookup(items)
359
+ export_data = [
360
+ self._build_record(i, item, albums) for i, item in enumerate(items, start=1)
361
+ ]
362
+
363
+ export_data.sort(key=lambda x: sort_value(x.get(self.sort_key)))
364
+ if self.reverse_order:
365
+ export_data.reverse()
366
+
367
+ self.exported_playlists += 1
368
+ self.exported_tracks += len(export_data)
369
+ if to_stdout:
370
+ write_stdout(self.fields, export_data, self.file_formats[0])
371
+ click.echo(f"Exported {len(export_data)} tracks from '{name}'.", err=True)
372
+ return
373
+ write_file(filepath, self.fields, export_data, self.file_formats)
374
+ for ext in self.file_formats:
375
+ click.echo(
376
+ f"Exported {len(export_data)} tracks from '{name}' to {filepath}.{ext}",
377
+ )
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: exportify-cli
3
+ Version: 1.0.0
4
+ Summary: Export Spotify playlists to CSV or JSON from the command line.
5
+ Keywords: spotify,playlist,export,csv,json
6
+ License-Expression: GPL-3.0-only
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: click-option-group>=0.5
9
+ Requires-Dist: pathvalidate>=3.0
10
+ Requires-Dist: spotipy>=2.24
11
+ Requires-Dist: tabulate>=0.9
12
+ Requires-Dist: tqdm>=4.66
13
+ Requires-Python: >=3.13
14
+ Project-URL: Homepage, https://github.com/donmerendolo/exportify-cli
@@ -0,0 +1,8 @@
1
+ exportify_cli/__init__.py,sha256=HmaL2FePg0bK6MqDdW5qw-r2BE5rW1jPm4wLT2ERoYA,208
2
+ exportify_cli/cli.py,sha256=5f0g-gE6yiyGr1WLzOw8oJAKnD4O37eBKNzB-AoQ1Xk,13774
3
+ exportify_cli/config.py,sha256=73ATog0cFX6tVYQI1kltFBjNQpzbclGlHcBq_NcoZEs,8070
4
+ exportify_cli/export.py,sha256=ks2AuzQoR9j17A_mJWXN_777ZB7UZDIiVCGUMyiuar0,13157
5
+ exportify_cli-1.0.0.dist-info/WHEEL,sha256=YR4QUxGCbsyoOnWBVTv-p1I4yc3seKGPev46mvmc8Cg,81
6
+ exportify_cli-1.0.0.dist-info/entry_points.txt,sha256=XECO7oo_qnIej9rah5qv6CLJg_3pL26crvHG-V6BkTc,58
7
+ exportify_cli-1.0.0.dist-info/METADATA,sha256=o2-Rm64kmoLbghiTDyuIxZFphHHr0hIuzY-8XJlO-t4,480
8
+ exportify_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.29
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ exportify-cli = exportify_cli.cli:main
3
+