exportify-cli 1.0.0__tar.gz
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,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,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "exportify-cli"
|
|
3
|
+
version = "v1.0.0"
|
|
4
|
+
description = "Export Spotify playlists to CSV or JSON from the command line."
|
|
5
|
+
requires-python = ">=3.13"
|
|
6
|
+
license = "GPL-3.0-only"
|
|
7
|
+
keywords = ["spotify", "playlist", "export", "csv", "json"]
|
|
8
|
+
dependencies = [
|
|
9
|
+
"click>=8.1",
|
|
10
|
+
"click-option-group>=0.5",
|
|
11
|
+
"pathvalidate>=3.0",
|
|
12
|
+
"spotipy>=2.24",
|
|
13
|
+
"tabulate>=0.9",
|
|
14
|
+
"tqdm>=4.66",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
exportify-cli = "exportify_cli.cli:main"
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/donmerendolo/exportify-cli"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.6,<0.12"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = ["pytest>=8"]
|
|
@@ -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
|
+
)
|