Skip to content

Commit 9ba09ee

Browse files
committed
added --user feature
1 parent f819227 commit 9ba09ee

File tree

5 files changed

+151
-57
lines changed

5 files changed

+151
-57
lines changed

.github/workflows/build-release.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ on:
66
tags: [ 'v*' ]
77
workflow_dispatch:
88

9+
permissions:
10+
actions: none
11+
contents: write
12+
913
jobs:
1014
build:
1115
runs-on: ${{ matrix.os }}
@@ -40,7 +44,9 @@ jobs:
4044
run: uv sync
4145

4246
- name: Build executable
43-
run: uvx pyinstaller --onefile --name ${{ matrix.binary_name }} exportify-cli.py
47+
run: |
48+
uv add pyinstaller
49+
uv run pyinstaller --onefile --name ${{ matrix.binary_name }} exportify-cli.py
4450
4551
- name: Upload artifact
4652
uses: actions/upload-artifact@v5
@@ -68,4 +74,4 @@ jobs:
6874
exportify-cli-macos-intel/exportify-cli-macos-intel
6975
exportify-cli-macos-arm64/exportify-cli-macos-arm64
7076
env:
71-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
77+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

README.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Export Spotify playlists to CSV or JSON directly from the terminal, inspired by
66
This tool can export all saved playlists, including liked songs.
77

88
## Installation:
9-
**If you use Windows, you can download the [binary](https://github.com/donmerendolo/exportify-cli/releases/latest/download/exportify-cli.exe) and skip steps 1 and 2. It's recommended to place it in a dedicated folder for better organization.**
9+
**You can also download a binary from the [releases page](https://github.com/donmerendolo/exportify-cli/releases/latest) and skip steps 1 and 2. It's recommended to place it in a dedicated folder since it will create .cache, a config file and playlists folders.**
1010
1. **Clone this repository:**
1111
```bash
1212
git clone https://github.com/donmerendolo/exportify-cli.git
@@ -34,23 +34,26 @@ Now after creating the app, press the Settings button on the upper right corner.
3434
Copy the Client ID, Client Secret and Redirect URI and paste them below.
3535
```
3636

37-
After running `python exportify-cli.py` (or [`exportify-cli.exe`](https://github.com/donmerendolo/exportify-cli/releases/latest/download/exportify-cli.exe) if you use the Windows binary) the first time, it should keep you authenticated so you don't have to log in each time.
37+
After running `python exportify-cli.py` (or [one of the binaries](https://github.com/donmerendolo/exportify-cli/releases/latest)) the first time, it should keep you authenticated so you don't have to log in each time.
3838

3939
If you wish to log out, simply remove the `.cache` file (you may also have to remove access to `exportify-cli` in https://www.spotify.com/us/account/apps/).
4040

4141
---
4242

4343
## Usage:
4444
```
45-
Usage: exportify-cli.py (-a | -p NAME|ID|URL|URI [-p ...] | -l) [OPTIONS]
45+
Usage: exportify-cli.py (-a | -p NAME|ID|URL|URI [-p ...] | -u ID|URL|URI |
46+
-l) [OPTIONS]
4647
4748
Export Spotify playlists to CSV or JSON.
4849
4950
Options:
5051
-a, --all Export all playlists
5152
-p, --playlist NAME|ID|URL|URI
52-
Spotify playlist name, ID, URL, or URI;
53-
repeatable.
53+
Export a Spotify playlist given name, ID,
54+
URL, or URI; repeatable.
55+
-u, --user ID|URL|URI Export all public playlists of a Spotify
56+
user given ID, URL, or URI; repeatable.
5457
-l, --list List available playlists.
5558
-c, --config PATH Path to configuration file (default is
5659
./config.cfg next to this script).
@@ -74,7 +77,9 @@ Options:
7477

7578
- You can also export a playlist that's not saved in your library by using its ID, URL, or URI.
7679

77-
- A single command can export multiple playlists by using the `-p` option multiple times.
80+
- A single command can export multiple playlists by using the `-p` option multiple times. Same applies for the `-u` option.
81+
82+
- You can export all ***public*** playlists of any user by using the `-u` option with their user ID, URL, or URI. It won't save Liked Songs from that user, as it's private.
7883

7984
- The default fields are: `Position`, `Track URI`, `Track Name`, `Album Name`, `Artist Name(s)`, `Release Date`, `Duration_ms`, `Popularity`, `Added By`, `Added At`, `Record Label`. With flags, `Album URI(s)`, `Artist URI(s)`, `Track ISRC` and `Album UPC` can be included too. If you want any other field to be added, feel free to open an issue or PR.
8085

@@ -97,6 +102,9 @@ exportify-cli.exe -f json -f csv -p https://open.spotify.com/playlist/2VqAIceMCz
97102
98103
# Export playlists "Instrumental" and "COCHE" to CSV without progress bar, sorted by Popularity
99104
python exportify-cli.py -p instr -p COCHE -f csv --no-bar --sort-key "popularity"
105+
106+
# Export all public playlists of user with ID "spotifyuser123" and user with URL "https://open.spotify.com/user/anotheruser456"
107+
python exportify-cli.py -u spotifyuser123 -u https://open.spotify.com/user/anotheruser456
100108
```
101109

102110
---
@@ -105,4 +113,4 @@ python exportify-cli.py -p instr -p COCHE -f csv --no-bar --sort-key "popularity
105113
I used [PyInstaller](https://pyinstaller.readthedocs.io/en/stable/) to build the windows binary with this command:
106114
```bash
107115
pyinstaller --onefile .\exportify-cli.py
108-
```
116+
```

exportify-cli.py

Lines changed: 116 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import click
1212
import spotipy
1313
from click_option_group import OptionGroup, optgroup
14+
from pathvalidate import sanitize_filename
1415
from spotipy.oauth2 import SpotifyOAuth
1516
from tabulate import tabulate
1617
from tqdm.auto import tqdm
@@ -153,33 +154,24 @@ def init_spotify_client(cfg: configparser.ConfigParser) -> spotipy.Spotify:
153154
return spotipy.Spotify(auth_manager=auth, retries=10)
154155

155156

156-
def sanitize_filename(name: str) -> str:
157-
"""Convert a playlist name into a safe filename."""
158-
safe = "".join(c if c.isalnum() or c in (" ", "_", "-") else "_" for c in name)
159-
return f"{safe.strip().replace(' ', '_').lower()}"
160-
161-
162-
def write_file(file_path: Path, data: list[dict], file_formats) -> None:
157+
def write_file(
158+
file_path: Path, headers: list[str], data: list[dict], file_formats
159+
) -> None:
163160
"""Write list of dicts to file."""
164-
if not data:
165-
logger.warning("No data to write; skipping file.")
166-
return
167-
168161
if "csv" in file_formats:
169-
headers = list(data[0].keys())
170-
with file_path.with_suffix(".csv").open(
171-
"w", newline="", encoding="utf-8"
172-
) as csvfile:
162+
csv_path = Path(str(file_path) + ".csv")
163+
with csv_path.open("w", newline="", encoding="utf-8") as csvfile:
173164
writer = csv.DictWriter(csvfile, fieldnames=headers)
174165
writer.writeheader()
175166
for row in data:
176167
writer.writerow(row)
177-
logger.info(f"Exported to {file_path}.csv")
168+
logger.info(f"Exported to {csv_path}")
178169

179170
if "json" in file_formats:
180-
with file_path.with_suffix(".json").open("w", encoding="utf-8") as jsonfile:
171+
json_path = Path(str(file_path) + ".json")
172+
with json_path.open("w", encoding="utf-8") as jsonfile:
181173
json.dump(data, jsonfile, ensure_ascii=False, indent=4)
182-
logger.info(f"Exported to {file_path}.json")
174+
logger.info(f"Exported to {json_path}")
183175

184176

185177
class SpotifyExporter:
@@ -347,7 +339,7 @@ def export_playlist(self, playlist: dict, output_dir: Path) -> None:
347339
"""Export a single playlist to CSV file."""
348340
name, pid = playlist["name"], playlist["id"]
349341
output_dir.mkdir(parents=True, exist_ok=True)
350-
filepath = output_dir / sanitize_filename(name)
342+
filepath = output_dir / sanitize_filename(f"{name} [{pid}]")
351343

352344
# Format description for progress bar
353345
desc = (
@@ -422,6 +414,23 @@ def export_playlist(self, playlist: dict, output_dir: Path) -> None:
422414

423415
# Build export data
424416
export_data = []
417+
headers = [
418+
"Position",
419+
"Track URI",
420+
"Artist URI(s)",
421+
"Album URI",
422+
"Track Name",
423+
"Album Name",
424+
"Artist Name(s)",
425+
"Release Date",
426+
"Duration_ms",
427+
"Popularity",
428+
"Added By",
429+
"Added At",
430+
"Record Label",
431+
"Track ISRC",
432+
"Album UPC",
433+
]
425434
for i, item in enumerate(items, start=1):
426435
track = item.get("track") or {}
427436
album = albums.get(track.get("album", {}).get("id"), {})
@@ -430,23 +439,28 @@ def export_playlist(self, playlist: dict, output_dir: Path) -> None:
430439
a.get("uri") for a in track.get("artists", []) if a.get("uri")
431440
]
432441

433-
record = {
434-
"Position": i,
435-
"Track URI": track.get("uri"),
436-
"Artist URI(s)": artist_uris,
437-
"Album URI": album.get("uri"),
438-
"Track Name": track.get("name"),
439-
"Album Name": album.get("name"),
440-
"Artist Name(s)": artists,
441-
"Release Date": album.get("release_date") or track.get("release_date"),
442-
"Duration_ms": track.get("duration_ms"),
443-
"Popularity": track.get("popularity"),
444-
"Added By": item.get("added_by", {}).get("id"),
445-
"Added At": item.get("added_at"),
446-
"Record Label": album.get("label"),
447-
"Track ISRC": track.get("external_ids", {}).get("isrc"),
448-
"Album UPC": album.get("external_ids", {}).get("upc"),
449-
}
442+
record = dict(
443+
zip(
444+
headers,
445+
[
446+
i,
447+
track.get("uri"),
448+
artist_uris,
449+
album.get("uri"),
450+
track.get("name"),
451+
album.get("name"),
452+
artists,
453+
album.get("release_date") or track.get("release_date"),
454+
track.get("duration_ms"),
455+
track.get("popularity"),
456+
item.get("added_by", {}).get("id"),
457+
item.get("added_at"),
458+
album.get("label"),
459+
track.get("external_ids", {}).get("isrc"),
460+
album.get("external_ids", {}).get("upc"),
461+
],
462+
)
463+
)
450464

451465
export_data.append(record)
452466

@@ -457,15 +471,19 @@ def export_playlist(self, playlist: dict, output_dir: Path) -> None:
457471
export_data.reverse()
458472

459473
if not self.include_uris:
474+
headers.pop(headers.index("Artist URI(s)"))
475+
headers.pop(headers.index("Album URI"))
460476
for record in export_data:
461477
record.pop("Artist URI(s)", None)
462478
record.pop("Album URI", None)
463479
if not self.external_ids:
480+
headers.pop(headers.index("Track ISRC"))
481+
headers.pop(headers.index("Album UPC"))
464482
for record in export_data:
465483
record.pop("Track ISRC", None)
466484
record.pop("Album UPC", None)
467485

468-
write_file(filepath, export_data, self.file_formats)
486+
write_file(filepath, headers, export_data, self.file_formats)
469487
self.exported_playlists += 1
470488
self.exported_tracks += len(export_data)
471489
for ext in self.file_formats:
@@ -479,7 +497,7 @@ class CustomCommand(click.Command):
479497
def format_usage(self, ctx, formatter) -> None:
480498
# Override the usage display
481499
formatter.write_text(
482-
"Usage: exportify-cli.py (-a | -p NAME|ID|URL|URI [-p ...] | -l) [OPTIONS]\n",
500+
"Usage: exportify-cli.py (-a | -p NAME|ID|URL|URI [-p ...] | -u ID|URL|URI | -l) [OPTIONS]\n",
483501
)
484502

485503

@@ -492,7 +510,15 @@ def format_usage(self, ctx, formatter) -> None:
492510
"playlist",
493511
multiple=True,
494512
metavar="NAME|ID|URL|URI",
495-
help="Spotify playlist name, ID, URL, or URI; repeatable.",
513+
help="Export a Spotify playlist given name, ID, URL, or URI; repeatable.",
514+
)
515+
@optgroup.option(
516+
"-u",
517+
"--user",
518+
"user",
519+
multiple=True,
520+
metavar="ID|URL|URI",
521+
help="Export all public playlists of a Spotify user given ID, URL, or URI; repeatable.",
496522
)
497523
@optgroup.option(
498524
"-l",
@@ -562,7 +588,7 @@ def format_usage(self, ctx, formatter) -> None:
562588
)
563589
@click.help_option("-h", "--help")
564590
@click.version_option(
565-
"0.3",
591+
"0.4",
566592
"-v",
567593
"--version",
568594
prog_name="exportify-cli",
@@ -571,6 +597,7 @@ def format_usage(self, ctx, formatter) -> None:
571597
def main(
572598
export_all: bool,
573599
playlist: tuple[str, ...],
600+
user: tuple[str, ...],
574601
list_only: bool,
575602
config: None | str,
576603
output_param: str,
@@ -582,8 +609,8 @@ def main(
582609
reverse_order: bool,
583610
) -> None:
584611
"""Export Spotify playlists to CSV or JSON."""
585-
# If no --all, --playlist or --list options are given, show help
586-
if not export_all and not playlist and not list_only:
612+
# If no --all, --playlist, --user or --list options are given, show help
613+
if not (export_all or playlist or user or list_only):
587614
click.echo(main.get_help(ctx=click.get_current_context()))
588615
sys.exit(1)
589616

@@ -643,12 +670,12 @@ def main(
643670
# Find the actual key that matches case-insensitively
644671
actual_key = None
645672
found = False
673+
674+
def normalize_key(s: str) -> str:
675+
return re.sub(r"[\s_()]", "", s.lower())
676+
646677
for key in keys:
647-
if key.lower().replace(" ", "").replace("_", "").replace("(", "").replace(
648-
")", ""
649-
) == sort_key.lower().replace(" ", "").replace("_", "").replace(
650-
"(", ""
651-
).replace(")", ""):
678+
if normalize_key(key) == normalize_key(sort_key):
652679
actual_key = key
653680
found = True
654681
break
@@ -699,6 +726,35 @@ def main(
699726
)
700727
sys.exit(0)
701728

729+
users_targets = []
730+
if user:
731+
user_ids = []
732+
for u in user:
733+
uid = re.sub(r"^.*users?\/([a-zA-Z0-9]+).*$", r"\1", u)
734+
uid = uid.replace("spotify:user:", "")
735+
user_ids.append(uid)
736+
737+
for uid in user_ids:
738+
try:
739+
items = exporter._fetch_all_items(
740+
client.user_playlists,
741+
"items",
742+
uid,
743+
desc=f"Playlists of {uid}",
744+
show_bar=False,
745+
)
746+
user_data = client.user(uid)
747+
748+
users_targets.append(
749+
{
750+
"name": user_data.get("display_name") or uid,
751+
"uid": uid,
752+
"items": items,
753+
}
754+
)
755+
except spotipy.SpotifyException as e:
756+
logger.warning(f"Failed to fetch playlists for user {uid}: {e}")
757+
702758
# Determine targets
703759
targets = []
704760
if export_all:
@@ -735,17 +791,29 @@ def main(
735791
except spotipy.SpotifyException as e:
736792
logger.warning(f"Failed to fetch playlist {term}: {e}")
737793

794+
if users_targets:
795+
for ut in users_targets:
796+
ut["targets"] = []
797+
for t in ut["items"]:
798+
if t["uri"]:
799+
ut["targets"].append(t)
800+
738801
# Deduplicate
739802
targets = list({p["id"]: p for p in targets}.values())
740803

741-
if not targets:
804+
if not (targets or users_targets):
742805
click.echo("No matching playlists found.")
743806
sys.exit(1)
744807

745808
out_dir = Path(output)
746809
for pl in targets:
747810
exporter.export_playlist(pl, out_dir)
748811

812+
for ut in users_targets:
813+
user_out_dir = out_dir / Path(sanitize_filename(f"{ut['name']} [{ut['uid']}]"))
814+
for pl in ut["targets"]:
815+
exporter.export_playlist(pl, user_out_dir)
816+
749817
if exporter.exported_playlists > 1:
750818
click.echo(
751819
f"Successfully exported {exporter.exported_tracks} tracks "

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ requires-python = ">=3.11"
77
dependencies = [
88
"click>=8.1.8",
99
"click-option-group>=0.5.7",
10+
"pathvalidate>=3.3.1",
1011
"pyinstaller>=6.13.0",
1112
"requests>=2.32.3",
1213
"spotipy>=2.25.1",

0 commit comments

Comments
 (0)