trackfetch 2.1.0__tar.gz → 2.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trackfetch
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Turn a text file of songs into tagged MP3s: Spotify metadata and cover art, best-match YouTube audio via yt-dlp.
5
5
  Author: ByteMe6
6
6
  License-Expression: GPL-3.0-or-later
@@ -195,7 +195,7 @@ EOF
195
195
  trackfetch songs.txt
196
196
  ```
197
197
 
198
- The files land in `~/Music/trackfetch/`.
198
+ The files land in the current directory.
199
199
 
200
200
  ## Playlists from streaming services
201
201
 
@@ -213,16 +213,19 @@ trackfetch "My Playlist.txt" -o ~/Music/"My Playlist"
213
213
  ## Usage
214
214
 
215
215
  ```text
216
- trackfetch [-h] [-o OUTPUT] [-f {mp3,m4a,opus}] [--title-only] [--delay DELAY] input
216
+ trackfetch [-h] [-o OUTPUT] [--config CONFIG] [-f {mp3,m4a,opus}] [--title-only] [--delay DELAY] [--spotify-client-id ID] [--spotify-client-secret SECRET] [input]
217
217
  ```
218
218
 
219
219
  | Option | Default | What it does |
220
220
  | --- | --- | --- |
221
- | `input` | required | Text file with one `Artist - Title` per line |
222
- | `-o`, `--output` | `~/Music/trackfetch` | Folder to save the files in. It's created if it doesn't exist. |
223
- | `-f`, `--format` | `mp3` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
224
- | `--title-only` | off | Name files `Title.mp3` instead of `Artist - Title.mp3` |
221
+ | `input` | required | Text file with one `Artist - Title` per line. Can also be set in the [config file](#configuration-file). |
222
+ | `-o`, `--output` | current directory | Folder to save the files in. It's created if it doesn't exist. |
223
+ | `--config` | auto-detected | Path to a [config file](#configuration-file). |
224
+ | `-f`, `--format` | `m4a` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
225
+ | `--title-only` | off | Name files `Title.m4a` instead of `Artist - Title.m4a` |
225
226
  | `--delay` | `1.0` | Seconds to wait between songs. Raise it for long lists. |
227
+ | `--spotify-client-id` | `$SPOTIFY_CLIENT_ID` | Spotify API client ID. See [Spotify credentials](#spotify-credentials). |
228
+ | `--spotify-client-secret` | `$SPOTIFY_CLIENT_SECRET` | Spotify API client secret. |
226
229
 
227
230
  ```bash
228
231
  trackfetch songs.txt -o ~/Music/RoadTrip # save to a specific folder
@@ -231,13 +234,66 @@ trackfetch big-list.txt --delay 3 # go easy on rate limits
231
234
  trackfetch songs.txt --format opus # original YouTube audio, no re-encode
232
235
  ```
233
236
 
237
+ ### Configuration file
238
+
239
+ Put your defaults in an INI file and forget the flags:
240
+
241
+ ```ini
242
+ [trackfetch]
243
+ output = ~/Music/trackfetch
244
+ format = m4a
245
+ title_only = false
246
+ delay = 1.0
247
+ input = ~/songs.txt
248
+ spotify_client_id = your-client-id
249
+ spotify_client_secret = your-client-secret
250
+ ```
251
+
252
+ Every option above can be set there, under the `[trackfetch]` section. The
253
+ precedence is always the same:
254
+
255
+ 1. the command-line flag,
256
+ 2. the config file,
257
+ 3. the built-in default (the current directory for the output folder).
258
+
259
+ trackfetch looks for the config file in this order: the path given with
260
+ `--config`, the path in `$TRACKFETCH_CONFIG`, `trackfetch.ini` in the current
261
+ directory, `$XDG_CONFIG_HOME/trackfetch/trackfetch.ini` (usually
262
+ `~/.config/trackfetch/trackfetch.ini`) and `~/.trackfetch.ini`.
263
+
264
+ ### Spotify credentials
265
+
266
+ trackfetch needs a Spotify client ID and secret to read the metadata. Give
267
+ them in whichever way suits you:
268
+
269
+ ```bash
270
+ # in the config file
271
+ [trackfetch]
272
+ spotify_client_id = your-client-id
273
+ spotify_client_secret = your-client-secret
274
+ ```
275
+
276
+ ```bash
277
+ # in the environment
278
+ export SPOTIFY_CLIENT_ID='your-client-id'
279
+ export SPOTIFY_CLIENT_SECRET='your-client-secret'
280
+ ```
281
+
282
+ ```bash
283
+ # on the command line
284
+ trackfetch songs.txt --spotify-client-id your-id --spotify-client-secret your-secret
285
+ ```
286
+
287
+ The flag wins over the config file, and the config file wins over the
288
+ environment variable.
289
+
234
290
  ### Audio formats
235
291
 
236
292
  | Format | What you get | Plays on |
237
293
  | --- | --- | --- |
238
- | `mp3` (default) | YouTube's audio **re-encoded** to the best VBR MP3. A second lossy encode, so slightly below the source | everything, including old players and car stereos |
294
+ | `m4a` (default) | YouTube's **original** AAC audio when available, copied without re-encoding | Apple devices, iTunes, most players |
239
295
  | `opus` | YouTube's **original** Opus audio, copied without re-encoding. The best quality you can get | most modern players, Android, browsers; not iTunes or older devices |
240
- | `m4a` | YouTube's **original** AAC audio when available, copied without re-encoding | Apple devices, iTunes, most players |
296
+ | `mp3` | YouTube's audio **re-encoded** to the best VBR MP3. A second lossy encode, so slightly below the source | everything, including old players and car stereos |
241
297
 
242
298
  All three get the same tags and embedded cover art. `done.txt` doesn't track the format, so use a separate `-o` folder for each format.
243
299
 
@@ -254,9 +310,9 @@ The separator is space, hyphen, space (` - `). Lines without it are skipped. See
254
310
  ### Output folder
255
311
 
256
312
  ```text
257
- ~/Music/trackfetch/
258
- ├── Daft Punk - One More Time.mp3
259
- ├── Radiohead - Paranoid Android.mp3
313
+ ./
314
+ ├── Daft Punk - One More Time.m4a
315
+ ├── Radiohead - Paranoid Android.m4a
260
316
  ├── done.txt ← finished songs, skipped on the next run
261
317
  └── failed.txt ← "Artist - Title | reason" for each song that failed
262
318
  ```
@@ -264,7 +320,7 @@ The separator is space, hyphen, space (` - `). Lines without it are skipped. See
264
320
  **Retry failures** by stripping the reasons from `failed.txt` and running it again:
265
321
 
266
322
  ```bash
267
- cut -d '|' -f 1 ~/Music/trackfetch/failed.txt > retry.txt
323
+ cut -d '|' -f 1 ./failed.txt > retry.txt
268
324
  trackfetch retry.txt
269
325
  ```
270
326
 
@@ -164,7 +164,7 @@ EOF
164
164
  trackfetch songs.txt
165
165
  ```
166
166
 
167
- The files land in `~/Music/trackfetch/`.
167
+ The files land in the current directory.
168
168
 
169
169
  ## Playlists from streaming services
170
170
 
@@ -182,16 +182,19 @@ trackfetch "My Playlist.txt" -o ~/Music/"My Playlist"
182
182
  ## Usage
183
183
 
184
184
  ```text
185
- trackfetch [-h] [-o OUTPUT] [-f {mp3,m4a,opus}] [--title-only] [--delay DELAY] input
185
+ trackfetch [-h] [-o OUTPUT] [--config CONFIG] [-f {mp3,m4a,opus}] [--title-only] [--delay DELAY] [--spotify-client-id ID] [--spotify-client-secret SECRET] [input]
186
186
  ```
187
187
 
188
188
  | Option | Default | What it does |
189
189
  | --- | --- | --- |
190
- | `input` | required | Text file with one `Artist - Title` per line |
191
- | `-o`, `--output` | `~/Music/trackfetch` | Folder to save the files in. It's created if it doesn't exist. |
192
- | `-f`, `--format` | `mp3` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
193
- | `--title-only` | off | Name files `Title.mp3` instead of `Artist - Title.mp3` |
190
+ | `input` | required | Text file with one `Artist - Title` per line. Can also be set in the [config file](#configuration-file). |
191
+ | `-o`, `--output` | current directory | Folder to save the files in. It's created if it doesn't exist. |
192
+ | `--config` | auto-detected | Path to a [config file](#configuration-file). |
193
+ | `-f`, `--format` | `m4a` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
194
+ | `--title-only` | off | Name files `Title.m4a` instead of `Artist - Title.m4a` |
194
195
  | `--delay` | `1.0` | Seconds to wait between songs. Raise it for long lists. |
196
+ | `--spotify-client-id` | `$SPOTIFY_CLIENT_ID` | Spotify API client ID. See [Spotify credentials](#spotify-credentials). |
197
+ | `--spotify-client-secret` | `$SPOTIFY_CLIENT_SECRET` | Spotify API client secret. |
195
198
 
196
199
  ```bash
197
200
  trackfetch songs.txt -o ~/Music/RoadTrip # save to a specific folder
@@ -200,13 +203,66 @@ trackfetch big-list.txt --delay 3 # go easy on rate limits
200
203
  trackfetch songs.txt --format opus # original YouTube audio, no re-encode
201
204
  ```
202
205
 
206
+ ### Configuration file
207
+
208
+ Put your defaults in an INI file and forget the flags:
209
+
210
+ ```ini
211
+ [trackfetch]
212
+ output = ~/Music/trackfetch
213
+ format = m4a
214
+ title_only = false
215
+ delay = 1.0
216
+ input = ~/songs.txt
217
+ spotify_client_id = your-client-id
218
+ spotify_client_secret = your-client-secret
219
+ ```
220
+
221
+ Every option above can be set there, under the `[trackfetch]` section. The
222
+ precedence is always the same:
223
+
224
+ 1. the command-line flag,
225
+ 2. the config file,
226
+ 3. the built-in default (the current directory for the output folder).
227
+
228
+ trackfetch looks for the config file in this order: the path given with
229
+ `--config`, the path in `$TRACKFETCH_CONFIG`, `trackfetch.ini` in the current
230
+ directory, `$XDG_CONFIG_HOME/trackfetch/trackfetch.ini` (usually
231
+ `~/.config/trackfetch/trackfetch.ini`) and `~/.trackfetch.ini`.
232
+
233
+ ### Spotify credentials
234
+
235
+ trackfetch needs a Spotify client ID and secret to read the metadata. Give
236
+ them in whichever way suits you:
237
+
238
+ ```bash
239
+ # in the config file
240
+ [trackfetch]
241
+ spotify_client_id = your-client-id
242
+ spotify_client_secret = your-client-secret
243
+ ```
244
+
245
+ ```bash
246
+ # in the environment
247
+ export SPOTIFY_CLIENT_ID='your-client-id'
248
+ export SPOTIFY_CLIENT_SECRET='your-client-secret'
249
+ ```
250
+
251
+ ```bash
252
+ # on the command line
253
+ trackfetch songs.txt --spotify-client-id your-id --spotify-client-secret your-secret
254
+ ```
255
+
256
+ The flag wins over the config file, and the config file wins over the
257
+ environment variable.
258
+
203
259
  ### Audio formats
204
260
 
205
261
  | Format | What you get | Plays on |
206
262
  | --- | --- | --- |
207
- | `mp3` (default) | YouTube's audio **re-encoded** to the best VBR MP3. A second lossy encode, so slightly below the source | everything, including old players and car stereos |
263
+ | `m4a` (default) | YouTube's **original** AAC audio when available, copied without re-encoding | Apple devices, iTunes, most players |
208
264
  | `opus` | YouTube's **original** Opus audio, copied without re-encoding. The best quality you can get | most modern players, Android, browsers; not iTunes or older devices |
209
- | `m4a` | YouTube's **original** AAC audio when available, copied without re-encoding | Apple devices, iTunes, most players |
265
+ | `mp3` | YouTube's audio **re-encoded** to the best VBR MP3. A second lossy encode, so slightly below the source | everything, including old players and car stereos |
210
266
 
211
267
  All three get the same tags and embedded cover art. `done.txt` doesn't track the format, so use a separate `-o` folder for each format.
212
268
 
@@ -223,9 +279,9 @@ The separator is space, hyphen, space (` - `). Lines without it are skipped. See
223
279
  ### Output folder
224
280
 
225
281
  ```text
226
- ~/Music/trackfetch/
227
- ├── Daft Punk - One More Time.mp3
228
- ├── Radiohead - Paranoid Android.mp3
282
+ ./
283
+ ├── Daft Punk - One More Time.m4a
284
+ ├── Radiohead - Paranoid Android.m4a
229
285
  ├── done.txt ← finished songs, skipped on the next run
230
286
  └── failed.txt ← "Artist - Title | reason" for each song that failed
231
287
  ```
@@ -233,7 +289,7 @@ The separator is space, hyphen, space (` - `). Lines without it are skipped. See
233
289
  **Retry failures** by stripping the reasons from `failed.txt` and running it again:
234
290
 
235
291
  ```bash
236
- cut -d '|' -f 1 ~/Music/trackfetch/failed.txt > retry.txt
292
+ cut -d '|' -f 1 ./failed.txt > retry.txt
237
293
  trackfetch retry.txt
238
294
  ```
239
295
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trackfetch"
7
- version = "2.1.0"
7
+ version = "2.2.0"
8
8
  description = "Turn a text file of songs into tagged MP3s: Spotify metadata and cover art, best-match YouTube audio via yt-dlp."
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
@@ -0,0 +1,346 @@
1
+ import argparse
2
+ import configparser
3
+ import shutil
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from conftest import FIXTURES, make_track
9
+
10
+ import trackfetch as m
11
+
12
+
13
+ @pytest.fixture
14
+ def pipeline(monkeypatch):
15
+ state = {
16
+ "track": make_track("One More Time", ["Daft Punk"], album="Discovery", track_number=1),
17
+ "video": {"title": "Daft Punk - One More Time (Official Audio)", "id": "abc"},
18
+ "formats": [],
19
+ }
20
+
21
+ def fake_download_audio(url, temp_dir, audio_format="mp3"):
22
+ state["formats"].append(audio_format)
23
+ path = temp_dir / f"audio.{audio_format}"
24
+ if audio_format == "mp3":
25
+ path.write_bytes(b"\x00" * 64)
26
+ else:
27
+ shutil.copy(FIXTURES / f"silence.{audio_format}", path)
28
+ return path
29
+
30
+ def fake_download_cover(url, dest):
31
+ dest.write_bytes(b"\xff\xd8\xff\xe0jpeg")
32
+
33
+ monkeypatch.setattr(m, "find_spotify_track", lambda sp, a, t: state["track"])
34
+ monkeypatch.setattr(m, "find_youtube_video", lambda a, t: state["video"])
35
+ monkeypatch.setattr(m, "download_audio", fake_download_audio)
36
+ monkeypatch.setattr(m, "download_cover", fake_download_cover)
37
+ monkeypatch.setattr(m, "get_spotify", lambda *a, **k: object())
38
+ monkeypatch.setattr(m.time, "sleep", lambda s: None)
39
+ return state
40
+
41
+
42
+ def run_cli(monkeypatch, *args):
43
+ monkeypatch.setattr(sys, "argv", ["trackfetch", *map(str, args)])
44
+ m.main()
45
+
46
+
47
+ def write_config(path: Path, body: str) -> Path:
48
+ path.parent.mkdir(parents=True, exist_ok=True)
49
+ path.write_text(body, encoding="utf-8")
50
+ return path
51
+
52
+
53
+ class TestConfigLookup:
54
+ def test_no_config_file_returns_empty_settings(self, tmp_path, monkeypatch):
55
+ monkeypatch.chdir(tmp_path)
56
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "empty"))
57
+ monkeypatch.delenv("TRACKFETCH_CONFIG", raising=False)
58
+ monkeypatch.setattr(m.Path, "home", classmethod(lambda cls: tmp_path / "home"))
59
+
60
+ settings, path = m.load_config()
61
+
62
+ assert settings == {}
63
+ assert path is None
64
+
65
+ def test_explicit_config_file_is_used(self, tmp_path):
66
+ config = write_config(tmp_path / "custom.ini", "[trackfetch]\noutput = /tmp/x\n")
67
+
68
+ settings, path = m.load_config(config)
69
+
70
+ assert settings["output"] == "/tmp/x"
71
+ assert path == config
72
+
73
+ def test_missing_explicit_config_file_raises(self, tmp_path):
74
+ with pytest.raises(FileNotFoundError):
75
+ m.load_config(tmp_path / "nope.ini")
76
+
77
+ def test_config_in_current_directory(self, tmp_path, monkeypatch):
78
+ monkeypatch.chdir(tmp_path)
79
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
80
+ monkeypatch.delenv("TRACKFETCH_CONFIG", raising=False)
81
+ monkeypatch.setattr(m.Path, "home", classmethod(lambda cls: tmp_path / "home"))
82
+ write_config(tmp_path / m.CONFIG_FILENAME, "[trackfetch]\noutput = /tmp/from-cwd\n")
83
+
84
+ settings, path = m.load_config()
85
+
86
+ assert settings["output"] == "/tmp/from-cwd"
87
+ assert path == tmp_path / m.CONFIG_FILENAME
88
+
89
+ def test_config_in_xdg_directory(self, tmp_path, monkeypatch):
90
+ monkeypatch.chdir(tmp_path)
91
+ monkeypatch.delenv("TRACKFETCH_CONFIG", raising=False)
92
+ monkeypatch.setattr(m.Path, "home", classmethod(lambda cls: tmp_path / "home"))
93
+ xdg = tmp_path / "xdg"
94
+ write_config(xdg / "trackfetch" / m.CONFIG_FILENAME, "[trackfetch]\noutput = /tmp/xdg\n")
95
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
96
+
97
+ settings, path = m.load_config()
98
+
99
+ assert settings["output"] == "/tmp/xdg"
100
+
101
+ def test_env_var_points_to_config(self, tmp_path, monkeypatch):
102
+ config = write_config(tmp_path / "env.ini", "[trackfetch]\noutput = /tmp/env\n")
103
+ monkeypatch.setenv("TRACKFETCH_CONFIG", str(config))
104
+
105
+ settings, path = m.load_config()
106
+
107
+ assert settings["output"] == "/tmp/env"
108
+
109
+ def test_invalid_config_file_raises(self, tmp_path):
110
+ config = write_config(tmp_path / "bad.ini", "not an ini file at all\n")
111
+
112
+ with pytest.raises(configparser.Error):
113
+ m.load_config(config)
114
+
115
+
116
+ class TestResolveOutput:
117
+ def test_flag_wins_over_config(self, tmp_path):
118
+ settings = {"output": str(tmp_path / "from-config")}
119
+
120
+ result = m.resolve_output(tmp_path / "from-flag", settings)
121
+
122
+ assert result == tmp_path / "from-flag"
123
+
124
+ def test_config_used_when_no_flag(self, tmp_path):
125
+ settings = {"output": str(tmp_path / "from-config")}
126
+
127
+ result = m.resolve_output(None, settings)
128
+
129
+ assert result == tmp_path / "from-config"
130
+
131
+ def test_current_directory_is_the_last_resort(self, tmp_path, monkeypatch):
132
+ monkeypatch.chdir(tmp_path)
133
+
134
+ assert m.resolve_output(None, {}) == tmp_path
135
+ assert m.resolve_output(None, {"output": " "}) == tmp_path
136
+
137
+ def test_tilde_is_expanded(self):
138
+ result = m.resolve_output(None, {"output": "~/Music/x"})
139
+
140
+ assert result == Path.home() / "Music" / "x"
141
+
142
+
143
+ class TestSpotifyCredentials:
144
+ def test_env_is_used_when_config_is_silent(self, monkeypatch):
145
+ monkeypatch.setenv("SPOTIFY_CLIENT_ID", "env-id")
146
+ monkeypatch.setenv("SPOTIFY_CLIENT_SECRET", "env-secret")
147
+
148
+ client_id, client_secret = m.spotify_credentials()
149
+
150
+ assert (client_id, client_secret) == ("env-id", "env-secret")
151
+
152
+ def test_config_wins_over_env(self, monkeypatch):
153
+ monkeypatch.setenv("SPOTIFY_CLIENT_ID", "env-id")
154
+ monkeypatch.setenv("SPOTIFY_CLIENT_SECRET", "env-secret")
155
+ settings = {
156
+ "spotify_client_id": "config-id",
157
+ "spotify_client_secret": "config-secret",
158
+ }
159
+
160
+ parser = argparse.ArgumentParser()
161
+ parser.add_argument("--spotify-client-id", default=None)
162
+ parser.add_argument("--spotify-client-secret", default=None)
163
+ args = parser.parse_args([])
164
+ m.apply_config(args, parser, settings)
165
+ client_id, client_secret = m.spotify_credentials(args)
166
+
167
+ assert (client_id, client_secret) == ("config-id", "config-secret")
168
+
169
+ def test_flag_wins_over_config_and_env(self, monkeypatch):
170
+ monkeypatch.setenv("SPOTIFY_CLIENT_ID", "env-id")
171
+ monkeypatch.setenv("SPOTIFY_CLIENT_SECRET", "env-secret")
172
+ parser = argparse.ArgumentParser()
173
+ parser.add_argument("--spotify-client-id", default=None)
174
+ parser.add_argument("--spotify-client-secret", default=None)
175
+ args = parser.parse_args(
176
+ ["--spotify-client-id", "flag-id", "--spotify-client-secret", "flag-secret"]
177
+ )
178
+ settings = {
179
+ "spotify_client_id": "config-id",
180
+ "spotify_client_secret": "config-secret",
181
+ }
182
+
183
+ m.apply_config(args, parser, settings)
184
+
185
+ client_id, client_secret = m.spotify_credentials(args)
186
+
187
+ assert (client_id, client_secret) == ("flag-id", "flag-secret")
188
+
189
+ def test_missing_credentials_exit(self, monkeypatch, capsys):
190
+ monkeypatch.delenv("SPOTIFY_CLIENT_ID", raising=False)
191
+ monkeypatch.delenv("SPOTIFY_CLIENT_SECRET", raising=False)
192
+
193
+ with pytest.raises(SystemExit) as exc:
194
+ m.get_spotify()
195
+
196
+ assert exc.value.code == 1
197
+ assert "Spotify credentials not found" in capsys.readouterr().out
198
+
199
+
200
+ class TestMainWithConfig:
201
+ def test_config_output_is_used(self, pipeline, monkeypatch, tmp_path):
202
+ songs = tmp_path / "songs.txt"
203
+ songs.write_text("Daft Punk - One More Time\n", encoding="utf-8")
204
+ out = tmp_path / "from-config"
205
+ config = write_config(tmp_path / "cfg.ini", f"[trackfetch]\noutput = {out}\n")
206
+
207
+ run_cli(monkeypatch, songs, "--config", config)
208
+
209
+ assert (out / "Daft Punk - One More Time.m4a").exists()
210
+
211
+ def test_flag_overrides_config(self, pipeline, monkeypatch, tmp_path):
212
+ songs = tmp_path / "songs.txt"
213
+ songs.write_text("Daft Punk - One More Time\n", encoding="utf-8")
214
+ config_out = tmp_path / "from-config"
215
+ flag_out = tmp_path / "from-flag"
216
+ config = write_config(tmp_path / "cfg.ini", f"[trackfetch]\noutput = {config_out}\n")
217
+
218
+ run_cli(monkeypatch, songs, "--config", config, "-o", flag_out)
219
+
220
+ assert (flag_out / "Daft Punk - One More Time.m4a").exists()
221
+ assert not config_out.exists()
222
+
223
+ def test_current_directory_is_used_without_config(self, pipeline, monkeypatch, tmp_path):
224
+ songs = tmp_path / "songs.txt"
225
+ songs.write_text("Daft Punk - One More Time\n", encoding="utf-8")
226
+ workdir = tmp_path / "work"
227
+ workdir.mkdir()
228
+ monkeypatch.chdir(workdir)
229
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
230
+ monkeypatch.delenv("TRACKFETCH_CONFIG", raising=False)
231
+ monkeypatch.setattr(m.Path, "home", classmethod(lambda cls: tmp_path / "home"))
232
+
233
+ run_cli(monkeypatch, songs)
234
+
235
+ assert (workdir / "Daft Punk - One More Time.m4a").exists()
236
+
237
+ def test_invalid_config_file_exits(self, monkeypatch, tmp_path, capsys):
238
+ songs = tmp_path / "songs.txt"
239
+ songs.write_text("A - B\n", encoding="utf-8")
240
+ config = write_config(tmp_path / "bad.ini", "not an ini file at all\n")
241
+
242
+ with pytest.raises(SystemExit) as exc:
243
+ run_cli(monkeypatch, songs, "--config", config)
244
+
245
+ assert exc.value.code == 1
246
+ assert "Invalid config file" in capsys.readouterr().out
247
+
248
+ def test_invalid_value_in_config_exits(self, monkeypatch, tmp_path, capsys):
249
+ songs = tmp_path / "songs.txt"
250
+ songs.write_text("A - B\n", encoding="utf-8")
251
+ config = write_config(tmp_path / "cfg.ini", "[trackfetch]\ndelay = abc\n")
252
+
253
+ with pytest.raises(SystemExit) as exc:
254
+ run_cli(monkeypatch, songs, "--config", config)
255
+
256
+ assert exc.value.code == 1
257
+ assert "Invalid value for 'delay' in config file" in capsys.readouterr().out
258
+
259
+ def test_missing_input_file_exits(self, monkeypatch, tmp_path, capsys):
260
+ with pytest.raises(SystemExit) as exc:
261
+ run_cli(monkeypatch)
262
+
263
+ assert exc.value.code == 1
264
+ assert "No input file" in capsys.readouterr().out
265
+
266
+ def test_input_can_come_from_the_config(self, pipeline, monkeypatch, tmp_path):
267
+ songs = tmp_path / "songs.txt"
268
+ songs.write_text("Daft Punk - One More Time\n", encoding="utf-8")
269
+ out = tmp_path / "o"
270
+ config = write_config(
271
+ tmp_path / "cfg.ini", f"[trackfetch]\ninput = {songs}\noutput = {out}\n"
272
+ )
273
+
274
+ run_cli(monkeypatch, "--config", config)
275
+
276
+ assert (out / "Daft Punk - One More Time.m4a").exists()
277
+
278
+ def test_missing_explicit_config_exits(self, monkeypatch, tmp_path, capsys):
279
+ songs = tmp_path / "songs.txt"
280
+ songs.write_text("A - B\n", encoding="utf-8")
281
+
282
+ with pytest.raises(SystemExit) as exc:
283
+ run_cli(monkeypatch, songs, "--config", tmp_path / "nope.ini")
284
+
285
+ assert exc.value.code == 1
286
+ assert "Config file not found" in capsys.readouterr().out
287
+
288
+ def test_format_from_config(self, pipeline, monkeypatch, tmp_path):
289
+ songs = tmp_path / "songs.txt"
290
+ songs.write_text("Daft Punk - One More Time\n", encoding="utf-8")
291
+ out = tmp_path / "o"
292
+ config = write_config(
293
+ tmp_path / "cfg.ini", f"[trackfetch]\noutput = {out}\nformat = opus\n"
294
+ )
295
+
296
+ run_cli(monkeypatch, songs, "--config", config)
297
+
298
+ assert (out / "Daft Punk - One More Time.opus").exists()
299
+ assert pipeline["formats"] == ["opus"]
300
+
301
+ def test_unknown_format_in_config_exits(self, monkeypatch, tmp_path, capsys):
302
+ songs = tmp_path / "songs.txt"
303
+ songs.write_text("A - B\n", encoding="utf-8")
304
+ config = write_config(tmp_path / "cfg.ini", "[trackfetch]\nformat = flac\n")
305
+
306
+ with pytest.raises(SystemExit) as exc:
307
+ run_cli(monkeypatch, songs, "--config", config)
308
+
309
+ assert exc.value.code == 1
310
+ assert "Unknown value for 'format' in config file" in capsys.readouterr().out
311
+
312
+ def test_title_only_from_config(self, pipeline, monkeypatch, tmp_path):
313
+ songs = tmp_path / "songs.txt"
314
+ songs.write_text("Daft Punk - One More Time\n", encoding="utf-8")
315
+ out = tmp_path / "o"
316
+ config = write_config(
317
+ tmp_path / "cfg.ini", f"[trackfetch]\noutput = {out}\ntitle_only = true\n"
318
+ )
319
+
320
+ run_cli(monkeypatch, songs, "--config", config)
321
+
322
+ assert (out / "One More Time.m4a").exists()
323
+
324
+ def test_delay_from_config(self, pipeline, monkeypatch, tmp_path):
325
+ songs = tmp_path / "songs.txt"
326
+ songs.write_text("A - 1\nA - 2\n", encoding="utf-8")
327
+ out = tmp_path / "o"
328
+ config = write_config(tmp_path / "cfg.ini", f"[trackfetch]\noutput = {out}\ndelay = 2.5\n")
329
+ sleeps = []
330
+ monkeypatch.setattr(m.time, "sleep", sleeps.append)
331
+
332
+ run_cli(monkeypatch, songs, "--config", config)
333
+
334
+ assert sleeps == [2.5]
335
+
336
+ def test_config_path_is_reported(self, pipeline, monkeypatch, tmp_path, capsys):
337
+ songs = tmp_path / "songs.txt"
338
+ songs.write_text("Daft Punk - One More Time\n", encoding="utf-8")
339
+ out = tmp_path / "o"
340
+ config = write_config(tmp_path / "cfg.ini", f"[trackfetch]\noutput = {out}\n")
341
+
342
+ run_cli(monkeypatch, songs, "--config", config)
343
+ log = capsys.readouterr().out
344
+
345
+ assert f"Config: {config}" in log
346
+ assert f"Output: {out}" in log
@@ -48,7 +48,7 @@ def pipeline(monkeypatch):
48
48
  monkeypatch.setattr(m, "find_youtube_video", lambda artist, title: state["video"])
49
49
  monkeypatch.setattr(m, "download_audio", fake_download_audio)
50
50
  monkeypatch.setattr(m, "download_cover", fake_download_cover)
51
- monkeypatch.setattr(m, "get_spotify", lambda: object())
51
+ monkeypatch.setattr(m, "get_spotify", lambda *a, **k: object())
52
52
  monkeypatch.setattr(m.time, "sleep", lambda s: None)
53
53
  return state
54
54
 
@@ -173,7 +173,7 @@ class TestMain:
173
173
  assert "Found 2 songs" in log
174
174
  assert "SUCCESS: 1" in log
175
175
  assert "FAILED: 1" in log
176
- assert (out / "Daft Punk - One More Time.mp3").exists()
176
+ assert (out / "Daft Punk - One More Time.m4a").exists()
177
177
  assert (out / "done.txt").read_text(encoding="utf-8") == "Daft Punk - One More Time\n"
178
178
  assert (out / "failed.txt").read_text(
179
179
  encoding="utf-8"
@@ -204,7 +204,7 @@ class TestMain:
204
204
  songs.write_text("Daft Punk - One More Time\n", encoding="utf-8")
205
205
  out = tmp_path / "o"
206
206
  out.mkdir()
207
- (out / "Daft Punk - One More Time.mp3").write_bytes(b"")
207
+ (out / "Daft Punk - One More Time.m4a").write_bytes(b"")
208
208
 
209
209
  run_cli(monkeypatch, songs, "-o", out)
210
210
  log = capsys.readouterr().out
@@ -220,12 +220,12 @@ class TestMain:
220
220
  assert (out / "Daft Punk - One More Time.opus").exists()
221
221
  assert pipeline["formats"] == ["opus"]
222
222
 
223
- def test_default_format_is_mp3(self, pipeline, monkeypatch, tmp_path):
223
+ def test_default_format_is_m4a(self, pipeline, monkeypatch, tmp_path):
224
224
  songs = tmp_path / "songs.txt"
225
225
  songs.write_text("Daft Punk - One More Time\n", encoding="utf-8")
226
226
 
227
227
  run_cli(monkeypatch, songs, "-o", tmp_path / "o")
228
- assert pipeline["formats"] == ["mp3"]
228
+ assert pipeline["formats"] == ["m4a"]
229
229
 
230
230
  def test_rejects_unknown_format(self, monkeypatch, tmp_path, capsys):
231
231
  songs = tmp_path / "songs.txt"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trackfetch
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Turn a text file of songs into tagged MP3s: Spotify metadata and cover art, best-match YouTube audio via yt-dlp.
5
5
  Author: ByteMe6
6
6
  License-Expression: GPL-3.0-or-later
@@ -195,7 +195,7 @@ EOF
195
195
  trackfetch songs.txt
196
196
  ```
197
197
 
198
- The files land in `~/Music/trackfetch/`.
198
+ The files land in the current directory.
199
199
 
200
200
  ## Playlists from streaming services
201
201
 
@@ -213,16 +213,19 @@ trackfetch "My Playlist.txt" -o ~/Music/"My Playlist"
213
213
  ## Usage
214
214
 
215
215
  ```text
216
- trackfetch [-h] [-o OUTPUT] [-f {mp3,m4a,opus}] [--title-only] [--delay DELAY] input
216
+ trackfetch [-h] [-o OUTPUT] [--config CONFIG] [-f {mp3,m4a,opus}] [--title-only] [--delay DELAY] [--spotify-client-id ID] [--spotify-client-secret SECRET] [input]
217
217
  ```
218
218
 
219
219
  | Option | Default | What it does |
220
220
  | --- | --- | --- |
221
- | `input` | required | Text file with one `Artist - Title` per line |
222
- | `-o`, `--output` | `~/Music/trackfetch` | Folder to save the files in. It's created if it doesn't exist. |
223
- | `-f`, `--format` | `mp3` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
224
- | `--title-only` | off | Name files `Title.mp3` instead of `Artist - Title.mp3` |
221
+ | `input` | required | Text file with one `Artist - Title` per line. Can also be set in the [config file](#configuration-file). |
222
+ | `-o`, `--output` | current directory | Folder to save the files in. It's created if it doesn't exist. |
223
+ | `--config` | auto-detected | Path to a [config file](#configuration-file). |
224
+ | `-f`, `--format` | `m4a` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
225
+ | `--title-only` | off | Name files `Title.m4a` instead of `Artist - Title.m4a` |
225
226
  | `--delay` | `1.0` | Seconds to wait between songs. Raise it for long lists. |
227
+ | `--spotify-client-id` | `$SPOTIFY_CLIENT_ID` | Spotify API client ID. See [Spotify credentials](#spotify-credentials). |
228
+ | `--spotify-client-secret` | `$SPOTIFY_CLIENT_SECRET` | Spotify API client secret. |
226
229
 
227
230
  ```bash
228
231
  trackfetch songs.txt -o ~/Music/RoadTrip # save to a specific folder
@@ -231,13 +234,66 @@ trackfetch big-list.txt --delay 3 # go easy on rate limits
231
234
  trackfetch songs.txt --format opus # original YouTube audio, no re-encode
232
235
  ```
233
236
 
237
+ ### Configuration file
238
+
239
+ Put your defaults in an INI file and forget the flags:
240
+
241
+ ```ini
242
+ [trackfetch]
243
+ output = ~/Music/trackfetch
244
+ format = m4a
245
+ title_only = false
246
+ delay = 1.0
247
+ input = ~/songs.txt
248
+ spotify_client_id = your-client-id
249
+ spotify_client_secret = your-client-secret
250
+ ```
251
+
252
+ Every option above can be set there, under the `[trackfetch]` section. The
253
+ precedence is always the same:
254
+
255
+ 1. the command-line flag,
256
+ 2. the config file,
257
+ 3. the built-in default (the current directory for the output folder).
258
+
259
+ trackfetch looks for the config file in this order: the path given with
260
+ `--config`, the path in `$TRACKFETCH_CONFIG`, `trackfetch.ini` in the current
261
+ directory, `$XDG_CONFIG_HOME/trackfetch/trackfetch.ini` (usually
262
+ `~/.config/trackfetch/trackfetch.ini`) and `~/.trackfetch.ini`.
263
+
264
+ ### Spotify credentials
265
+
266
+ trackfetch needs a Spotify client ID and secret to read the metadata. Give
267
+ them in whichever way suits you:
268
+
269
+ ```bash
270
+ # in the config file
271
+ [trackfetch]
272
+ spotify_client_id = your-client-id
273
+ spotify_client_secret = your-client-secret
274
+ ```
275
+
276
+ ```bash
277
+ # in the environment
278
+ export SPOTIFY_CLIENT_ID='your-client-id'
279
+ export SPOTIFY_CLIENT_SECRET='your-client-secret'
280
+ ```
281
+
282
+ ```bash
283
+ # on the command line
284
+ trackfetch songs.txt --spotify-client-id your-id --spotify-client-secret your-secret
285
+ ```
286
+
287
+ The flag wins over the config file, and the config file wins over the
288
+ environment variable.
289
+
234
290
  ### Audio formats
235
291
 
236
292
  | Format | What you get | Plays on |
237
293
  | --- | --- | --- |
238
- | `mp3` (default) | YouTube's audio **re-encoded** to the best VBR MP3. A second lossy encode, so slightly below the source | everything, including old players and car stereos |
294
+ | `m4a` (default) | YouTube's **original** AAC audio when available, copied without re-encoding | Apple devices, iTunes, most players |
239
295
  | `opus` | YouTube's **original** Opus audio, copied without re-encoding. The best quality you can get | most modern players, Android, browsers; not iTunes or older devices |
240
- | `m4a` | YouTube's **original** AAC audio when available, copied without re-encoding | Apple devices, iTunes, most players |
296
+ | `mp3` | YouTube's audio **re-encoded** to the best VBR MP3. A second lossy encode, so slightly below the source | everything, including old players and car stereos |
241
297
 
242
298
  All three get the same tags and embedded cover art. `done.txt` doesn't track the format, so use a separate `-o` folder for each format.
243
299
 
@@ -254,9 +310,9 @@ The separator is space, hyphen, space (` - `). Lines without it are skipped. See
254
310
  ### Output folder
255
311
 
256
312
  ```text
257
- ~/Music/trackfetch/
258
- ├── Daft Punk - One More Time.mp3
259
- ├── Radiohead - Paranoid Android.mp3
313
+ ./
314
+ ├── Daft Punk - One More Time.m4a
315
+ ├── Radiohead - Paranoid Android.m4a
260
316
  ├── done.txt ← finished songs, skipped on the next run
261
317
  └── failed.txt ← "Artist - Title | reason" for each song that failed
262
318
  ```
@@ -264,7 +320,7 @@ The separator is space, hyphen, space (` - `). Lines without it are skipped. See
264
320
  **Retry failures** by stripping the reasons from `failed.txt` and running it again:
265
321
 
266
322
  ```bash
267
- cut -d '|' -f 1 ~/Music/trackfetch/failed.txt > retry.txt
323
+ cut -d '|' -f 1 ./failed.txt > retry.txt
268
324
  trackfetch retry.txt
269
325
  ```
270
326
 
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  trackfetch.py
5
+ tests/test_config.py
5
6
  tests/test_pipeline.py
6
7
  tests/test_spotify.py
7
8
  tests/test_tags_and_files.py
@@ -3,6 +3,7 @@
3
3
 
4
4
  import argparse
5
5
  import base64
6
+ import configparser
6
7
  import json
7
8
  import os
8
9
  import re
@@ -34,6 +35,13 @@ from spotipy.oauth2 import SpotifyClientCredentials
34
35
 
35
36
  YTDLP_REMOTE_COMPONENTS = "ejs:github"
36
37
 
38
+ CONFIG_SECTION = "trackfetch"
39
+ CONFIG_FILENAME = "trackfetch.ini"
40
+ CONFIG_ENV = "TRACKFETCH_CONFIG"
41
+
42
+ DEFAULT_FORMAT = "m4a"
43
+ DEFAULT_DELAY = 1.0
44
+
37
45
  AUDIO_FORMATS = {
38
46
  "mp3": [],
39
47
  "m4a": [
@@ -84,13 +92,31 @@ def parse_song(line: str):
84
92
  return artist, title
85
93
 
86
94
 
87
- def get_spotify():
88
- client_id = os.getenv("SPOTIFY_CLIENT_ID")
89
- client_secret = os.getenv("SPOTIFY_CLIENT_SECRET")
95
+ def spotify_credentials(args=None):
96
+ """Spotify credentials: flag > config file > environment variable."""
97
+ client_id = None
98
+ client_secret = None
99
+
100
+ if args is not None:
101
+ client_id = getattr(args, "spotify_client_id", None)
102
+ client_secret = getattr(args, "spotify_client_secret", None)
103
+
104
+ client_id = client_id or os.getenv("SPOTIFY_CLIENT_ID")
105
+ client_secret = client_secret or os.getenv("SPOTIFY_CLIENT_SECRET")
106
+
107
+ return client_id, client_secret
108
+
109
+
110
+ def get_spotify(args=None):
111
+ client_id, client_secret = spotify_credentials(args)
90
112
 
91
113
  if not client_id or not client_secret:
92
114
  print("\nERROR: Spotify credentials not found.")
93
- print("Set:")
115
+ print("Set them in the config file:")
116
+ print("[trackfetch]")
117
+ print("spotify_client_id = '...'")
118
+ print("spotify_client_secret = '...'")
119
+ print("or in the environment:")
94
120
  print("export SPOTIFY_CLIENT_ID='...'")
95
121
  print("export SPOTIFY_CLIENT_SECRET='...'")
96
122
  sys.exit(1)
@@ -200,7 +226,7 @@ def download_cover(url: str, destination: Path):
200
226
  url,
201
227
  timeout=30,
202
228
  headers={
203
- "User-Agent": "trackfetch/2.1"
229
+ "User-Agent": "trackfetch/2.2"
204
230
  },
205
231
  )
206
232
 
@@ -695,6 +721,159 @@ def process_song(
695
721
  return True, "downloaded"
696
722
 
697
723
 
724
+ def config_candidates():
725
+ """Config file locations, in the order they are looked up."""
726
+ candidates = []
727
+
728
+ env = os.getenv(CONFIG_ENV)
729
+
730
+ if env:
731
+ candidates.append(Path(env).expanduser())
732
+
733
+ candidates.append(Path.cwd() / CONFIG_FILENAME)
734
+
735
+ xdg = os.getenv("XDG_CONFIG_HOME")
736
+
737
+ if xdg:
738
+ candidates.append(
739
+ Path(xdg).expanduser() / "trackfetch" / CONFIG_FILENAME
740
+ )
741
+
742
+ candidates.append(
743
+ Path.home() / ".config" / "trackfetch" / CONFIG_FILENAME
744
+ )
745
+ candidates.append(Path.home() / CONFIG_FILENAME)
746
+
747
+ return candidates
748
+
749
+
750
+ def find_config_file(explicit=None):
751
+ """Return the config file to use, or None if there is none."""
752
+ if explicit is not None:
753
+ return Path(explicit).expanduser()
754
+
755
+ for candidate in config_candidates():
756
+ if candidate.is_file():
757
+ return candidate
758
+
759
+ return None
760
+
761
+
762
+ def load_config(explicit=None):
763
+ """Read settings from a config file.
764
+
765
+ Returns a (settings, path) tuple. Settings is empty when no config file
766
+ exists. Raises FileNotFoundError when an explicitly requested file is
767
+ missing, and configparser.Error when the file cannot be parsed.
768
+ """
769
+ config_path = find_config_file(explicit)
770
+
771
+ if config_path is None:
772
+ return {}, None
773
+
774
+ if not config_path.is_file():
775
+ raise FileNotFoundError(f"Config file not found: {config_path}")
776
+
777
+ parser = configparser.ConfigParser()
778
+
779
+ with config_path.open(encoding="utf-8") as file:
780
+ parser.read_file(file)
781
+
782
+ settings = dict(parser.defaults())
783
+
784
+ if parser.has_section(CONFIG_SECTION):
785
+ settings.update(dict(parser[CONFIG_SECTION]))
786
+
787
+ return settings, config_path
788
+
789
+
790
+ def config_str_value(settings, key):
791
+ """Read a plain string setting, or None when it is missing or blank."""
792
+ value = (settings.get(key) or "").strip()
793
+
794
+ if not value:
795
+ return None
796
+
797
+ return value
798
+
799
+
800
+ def config_path_value(settings, key):
801
+ """Read a path-like setting, or None when it is missing or blank."""
802
+ value = config_str_value(settings, key)
803
+
804
+ if value is None:
805
+ return None
806
+
807
+ return Path(value).expanduser()
808
+
809
+
810
+ def resolve_output(cli_output, settings):
811
+ """Pick the output directory: flag > config file > current directory."""
812
+ if cli_output is not None:
813
+ return Path(cli_output).expanduser()
814
+
815
+ from_config = config_path_value(settings, "output")
816
+
817
+ if from_config is not None:
818
+ return from_config
819
+
820
+ return Path.cwd()
821
+
822
+
823
+ def coerce_config_value(action, raw):
824
+ """Convert a raw config value into the type the CLI option expects."""
825
+ if action.type is Path:
826
+ return Path(raw).expanduser()
827
+
828
+ if action.type is float:
829
+ return float(raw)
830
+
831
+ if action.type is int:
832
+ return int(raw)
833
+
834
+ if isinstance(action, argparse._StoreTrueAction):
835
+ return raw.lower() in ("1", "true", "yes", "on")
836
+
837
+ return raw
838
+
839
+
840
+ def apply_config(args, parser, settings):
841
+ """Fill options that were not given on the command line from the config.
842
+
843
+ The flag always wins, the config file is the second source, and the
844
+ built-in default is the last one.
845
+ """
846
+ for action in parser._actions:
847
+ dest = action.dest
848
+
849
+ if dest in ("help", "version", "config"):
850
+ continue
851
+
852
+ if getattr(args, dest, None) is not None:
853
+ continue
854
+
855
+ raw = config_str_value(settings, dest)
856
+
857
+ if raw is None:
858
+ continue
859
+
860
+ try:
861
+ value = coerce_config_value(action, raw)
862
+
863
+ except (TypeError, ValueError) as error:
864
+ raise ValueError(
865
+ f"Invalid value for '{dest}' in config file: {raw} ({error})"
866
+ ) from error
867
+
868
+ if action.choices and value not in action.choices:
869
+ raise ValueError(
870
+ f"Unknown value for '{dest}' in config file: {raw} "
871
+ f"(choose from {', '.join(action.choices)})"
872
+ )
873
+
874
+ setattr(args, dest, value)
875
+
876
+
698
877
  def main():
699
878
  parser = argparse.ArgumentParser(
700
879
  description=(
@@ -706,6 +885,7 @@ def main():
706
885
  parser.add_argument(
707
886
  "input",
708
887
  type=Path,
888
+ nargs="?",
709
889
  help="Text file with Artist - Title",
710
890
  )
711
891
 
@@ -713,33 +893,94 @@ def main():
713
893
  "-o",
714
894
  "--output",
715
895
  type=Path,
716
- default=Path.home() / "Music" / "trackfetch",
717
- help="Output directory",
896
+ default=None,
897
+ help=(
898
+ "Output directory "
899
+ "(overrides the config file, "
900
+ "defaults to the current directory)"
901
+ ),
902
+ )
903
+
904
+ parser.add_argument(
905
+ "--config",
906
+ type=Path,
907
+ default=None,
908
+ help="Path to a config file",
718
909
  )
719
910
 
720
911
  parser.add_argument(
721
912
  "-f",
722
913
  "--format",
723
914
  choices=list(AUDIO_FORMATS),
724
- default="mp3",
915
+ default=None,
725
916
  help="Audio format",
726
917
  )
727
918
 
728
919
  parser.add_argument(
729
920
  "--title-only",
730
921
  action="store_true",
922
+ default=None,
731
923
  help="Use only title in filename",
732
924
  )
733
925
 
734
926
  parser.add_argument(
735
927
  "--delay",
736
928
  type=float,
737
- default=1.0,
929
+ default=None,
738
930
  help="Delay between songs in seconds",
739
931
  )
740
932
 
933
+ parser.add_argument(
934
+ "--spotify-client-id",
935
+ default=None,
936
+ help="Spotify client ID (overrides the config file and the env)",
937
+ )
938
+
939
+ parser.add_argument(
940
+ "--spotify-client-secret",
941
+ default=None,
942
+ help="Spotify client secret (overrides the config file and the env)",
943
+ )
944
+
741
945
  args = parser.parse_args()
742
946
 
947
+ try:
948
+ settings, config_path = load_config(args.config)
949
+
950
+ except FileNotFoundError as error:
951
+ print(f"ERROR: {error}")
952
+ sys.exit(1)
953
+
954
+ except configparser.Error as error:
955
+ print(f"ERROR: Invalid config file: {error}")
956
+ sys.exit(1)
957
+
958
+ try:
959
+ apply_config(args, parser, settings)
960
+
961
+ except ValueError as error:
962
+ print(f"ERROR: {error}")
963
+ sys.exit(1)
964
+
965
+ if args.output is None:
966
+ args.output = resolve_output(None, settings)
967
+
968
+ if args.format is None:
969
+ args.format = DEFAULT_FORMAT
970
+
971
+ if args.title_only is None:
972
+ args.title_only = False
973
+
974
+ if args.delay is None:
975
+ args.delay = DEFAULT_DELAY
976
+
977
+ if args.input is None:
978
+ print(
979
+ "ERROR: No input file. "
980
+ "Pass it as an argument or set 'input' in the config file."
981
+ )
982
+ sys.exit(1)
983
+
743
984
  if not args.input.exists():
744
985
  print(f"ERROR: Input file not found: {args.input}")
745
986
  sys.exit(1)
@@ -773,9 +1014,13 @@ def main():
773
1014
 
774
1015
  print(f"Found {len(songs)} songs")
775
1016
  print(f"Already completed: {len(done)}")
1017
+
1018
+ if config_path:
1019
+ print(f"Config: {config_path}")
1020
+
776
1021
  print(f"Output: {args.output}")
777
1022
 
778
- sp = get_spotify()
1023
+ sp = get_spotify(args)
779
1024
 
780
1025
  success = 0
781
1026
  skipped = 0
File without changes
File without changes