Skip to content

Commit f267ef9

Browse files
committed
PKCE instead of oauth, --logout feature, uv sync instead of pip install, removed readme banner, updated and linted readme
1 parent 05cc734 commit f267ef9

4 files changed

Lines changed: 341 additions & 274 deletions

File tree

README.md

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,60 @@
1-
> [!CAUTION]
2-
> **THIS PROJECT IS UNMAINTAINED**
3-
>
4-
> As of [March 9, 2026](https://developer.spotify.com/blog/2026-02-06-update-on-developer-access-and-platform-security), Spotify requires an active Premium subscription to use their API. Since I don't have one, I'm unable to add features or fix bugs. If Spotify reverts this change, I'll continue maintaining it.
5-
61
# exportify-cli
2+
73
![exportify-cli](image.png)
84

95
Export Spotify playlists to CSV or JSON directly from the terminal, inspired by [pavelkomarov/exportify](https://github.com/pavelkomarov/exportify).
106

117
This tool can export all saved playlists, including liked songs.
128

139
## Installation:
10+
1411
**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.**
1512
1. **Clone this repository:**
13+
1614
```bash
1715
git clone https://github.com/donmerendolo/exportify-cli.git
1816
```
19-
2017
2. **Install the required packages:**
21-
(recommended to use a virtual environment)
18+
2219
```bash
2320
cd exportify-cli
24-
pip install -r requirements.txt
21+
uv sync
2522
```
2623

27-
3. **Set up Client ID, Client Secret and Redirect URI:**
24+
3. **Set up Client ID, a Redirect URI and a refresh token:**
2825

2926
The first time you run exportify-cli, it will guide you through the setup:
27+
3028
```
3129
File "config.cfg" not found or invalid. Let's create it.
30+
Use a Client ID and Redirect URI that belong to the same Spotify app.
31+
32+
Spotify Client ID:
33+
Redirect URI:
34+
```
3235

33-
1. Go to Spotify Developer Dashboard (https://developer.spotify.com/dashboard).
34-
2. Create a new app.
35-
3. Set a name and description for your app.
36-
4. Add a redirect URI (e.g. http://127.0.0.1:3000/callback).
36+
If no valid `.cache` token is found, exportify-cli asks for a `refresh_token` once to bootstrap authentication. Spotipy then manages and refreshes tokens automatically using the `.cache` file, so you won't have to enter it again unless you log out or revoke access:
3737

38-
Now after creating the app, press the Settings button on the upper right corner.
39-
Copy the Client ID, Client Secret and Redirect URI and paste them below.
38+
```
39+
No valid Spotify token cache found. Enter your refresh token.
40+
Spotify refresh token:
4041
```
4142

42-
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.
43+
If you wish to log out, run:
4344

44-
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/).
45+
```bash
46+
python exportify-cli.py --logout
47+
```
48+
49+
You may also revoke access in https://www.spotify.com/us/account/apps/.
4550

4651
---
4752

4853
## Usage:
54+
4955
```
50-
Usage: exportify-cli.py (-a | -p NAME|ID|URL|URI [-p ...] | -u ID|URL|URI |
51-
-l) [OPTIONS]
56+
Usage: exportify-cli.py (-a | -p NAME|ID|URL|URI [-p ...] | -u ID|URL|URI | -l
57+
| --logout) [OPTIONS]
5258
5359
Export Spotify playlists to CSV or JSON.
5460
@@ -60,6 +66,7 @@ Options:
6066
-u, --user ID|URL|URI Export all public playlists of a Spotify
6167
user given ID, URL, or URI; repeatable.
6268
-l, --list List available playlists.
69+
--logout Delete cached Spotify auth token and exit.
6370
-c, --config PATH Path to configuration file (default is
6471
./config.cfg next to this script).
6572
-o, --output PATH Directory to save exported files (default is
@@ -75,20 +82,15 @@ Options:
7582
-h, --help Show this message and exit.
7683
-v, --version Show the version and exit.
7784
```
78-
7985
- Default values can be changed in `config.cfg`.
80-
8186
- Playlist names support partial matching, provided they uniquely identify a single playlist.
82-
8387
- You can also export a playlist that's not saved in your library by using its ID, URL, or URI.
84-
8588
- A single command can export multiple playlists by using the `-p` option multiple times. Same applies for the `-u` option.
86-
8789
- 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.
88-
8990
- 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.
9091

9192
### Examples:
93+
9294
```
9395
# List all saved playlists
9496
python exportify-cli.py --list
@@ -115,7 +117,9 @@ python exportify-cli.py -u spotifyuser123 -u https://open.spotify.com/user/anoth
115117
---
116118

117119
## Building:
120+
118121
You can use [PyInstaller](https://pyinstaller.readthedocs.io/en/stable/) to build a binary with this command:
122+
119123
```bash
120124
pyinstaller --onefile .\exportify-cli.py
121125
```

exportify-cli.py

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import spotipy
1414
from click_option_group import OptionGroup, optgroup
1515
from pathvalidate import sanitize_filename
16-
from spotipy.oauth2 import SpotifyOAuth
16+
from spotipy.oauth2 import SpotifyOauthError, SpotifyPKCE
1717
from tabulate import tabulate
1818
from tqdm.auto import tqdm
1919

@@ -65,13 +65,18 @@ def get_version():
6565
return "0.0.0"
6666

6767

68+
def get_token_cache_path() -> Path:
69+
"""Return the path used by Spotipy to cache auth tokens."""
70+
return Path(__file__).parent / Path(".cache")
71+
72+
6873
def validate_config(config: configparser.ConfigParser) -> bool:
6974
"""Validate that the Spotify section exists and has all required keys, and that the redirect URI is valid."""
7075
if not config.has_section("spotify"):
7176
logger.error("Configuration missing [spotify] section.")
7277
return False
7378
spotify_cfg = config["spotify"]
74-
required = ("client_id", "client_secret", "redirect_uri")
79+
required = ("client_id", "redirect_uri")
7580
missing = [k for k in required if not spotify_cfg.get(k, "").strip()]
7681
if missing:
7782
logger.error(
@@ -114,27 +119,17 @@ def load_config(config_path: Path) -> configparser.ConfigParser:
114119
return config
115120
# Prompt user to create config
116121
logger.info(f"Config not found or invalid at {config_path}, creating new.")
117-
click.echo("""File "config.cfg" not found or invalid. Let's create it.
118-
119-
1. Go to Spotify Developer Dashboard (https://developer.spotify.com/dashboard).
120-
2. Create a new app.
121-
3. Set a name and description for your app.
122-
4. Add a redirect URI (e.g. http://127.0.0.1:3000/callback).
123-
124-
Now after creating the app, press the Settings button on the upper right corner.
125-
Copy the Client ID, Client Secret and Redirect URI and paste them below.""")
122+
click.echo(
123+
"""File "config.cfg" not found or invalid. Let's create it.
124+
Use a Client ID and Redirect URI that belong to the same Spotify app.
125+
"""
126+
)
126127

127128
spotify_cfg = {
128129
"client_id": click.prompt("Spotify Client ID", type=str).strip(),
129-
"client_secret": click.prompt(
130-
"Spotify Client Secret",
131-
hide_input=True,
132-
type=str,
133-
).strip(),
134130
"redirect_uri": click.prompt(
135131
"Redirect URI",
136132
type=str,
137-
default="http://127.0.0.1:3000/callback",
138133
).strip(),
139134
}
140135

@@ -159,15 +154,35 @@ def clean_playlist_input(playlist: list[str]) -> None:
159154

160155

161156
def init_spotify_client(cfg: configparser.ConfigParser) -> spotipy.Spotify:
162-
"""Initialize Spotify client with OAuth manager."""
157+
"""Initialize Spotify client with PKCE manager."""
163158
creds = cfg["spotify"]
164-
auth = SpotifyOAuth(
159+
auth = SpotifyPKCE(
165160
client_id=creds["client_id"],
166-
client_secret=creds["client_secret"],
167161
redirect_uri=creds["redirect_uri"],
168-
scope="playlist-read-private playlist-read-collaborative user-library-read",
169-
cache_path=Path(__file__).parent / Path(".cache"),
162+
cache_path=get_token_cache_path(),
170163
)
164+
165+
try:
166+
token_info = auth.get_cached_token()
167+
except SpotifyOauthError as err:
168+
logger.warning(
169+
f"Cached token is invalid or expired and could not be refreshed: {err}"
170+
)
171+
token_info = None
172+
173+
if token_info is None:
174+
click.echo("No valid Spotify token cache found. Enter your refresh token.")
175+
refresh_token = click.prompt("Spotify refresh token", type=str).strip()
176+
177+
try:
178+
auth.refresh_access_token(refresh_token)
179+
except SpotifyOauthError as err:
180+
logger.error(f"Failed to authenticate with provided refresh token: {err}")
181+
click.echo(
182+
"Authentication failed. The refresh token is invalid, expired, or revoked.",
183+
)
184+
sys.exit(1)
185+
171186
return spotipy.Spotify(auth_manager=auth, retries=10)
172187

173188

@@ -521,7 +536,7 @@ class CustomCommand(click.Command):
521536
def format_usage(self, ctx, formatter) -> None:
522537
# Override the usage display
523538
formatter.write_text(
524-
"Usage: exportify-cli.py (-a | -p NAME|ID|URL|URI [-p ...] | -u ID|URL|URI | -l) [OPTIONS]\n",
539+
"Usage: exportify-cli.py (-a | -p NAME|ID|URL|URI [-p ...] | -u ID|URL|URI | -l | --logout) [OPTIONS]\n",
525540
)
526541

527542

@@ -551,6 +566,12 @@ def format_usage(self, ctx, formatter) -> None:
551566
is_flag=True,
552567
help="List available playlists.",
553568
)
569+
@optgroup.option(
570+
"--logout",
571+
"logout",
572+
is_flag=True,
573+
help="Delete cached Spotify auth token and exit.",
574+
)
554575
@optgroup.option(
555576
"-c",
556577
"--config",
@@ -623,6 +644,7 @@ def main(
623644
playlist: tuple[str, ...],
624645
user: tuple[str, ...],
625646
list_only: bool,
647+
logout: bool,
626648
config: None | str,
627649
output_param: str,
628650
format_param: str,
@@ -633,6 +655,15 @@ def main(
633655
reverse_order: bool,
634656
) -> None:
635657
"""Export Spotify playlists to CSV or JSON."""
658+
if logout:
659+
token_cache = get_token_cache_path()
660+
if token_cache.exists():
661+
token_cache.unlink()
662+
click.echo('Logged out successfully. Removed token cache file ".cache".')
663+
else:
664+
click.echo('No token cache file ".cache" was found.')
665+
sys.exit(0)
666+
636667
# If no --all, --playlist, --user or --list options are given, show help
637668
if not (export_all or playlist or user or list_only):
638669
click.echo(main.get_help(ctx=click.get_current_context()))

0 commit comments

Comments
 (0)