trackfetch 2.1.0__tar.gz → 2.2.5__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.5
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,31 +213,104 @@ 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] [--track ARTIST - TITLE ...] [-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, or an inline `Artist - Title` track. Can also be set in the [config file](#configuration-file). |
222
+ | `--track` | — | Track to download inline, `Artist - Title`. Can be given several times. |
223
+ | `-o`, `--output` | current directory | Folder to save the files in. It's created if it doesn't exist. |
224
+ | `--config` | auto-detected | Path to a [config file](#configuration-file). |
225
+ | `-f`, `--format` | `m4a` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
226
+ | `--title-only` | off | Name files `Title.m4a` instead of `Artist - Title.m4a` |
225
227
  | `--delay` | `1.0` | Seconds to wait between songs. Raise it for long lists. |
228
+ | `--spotify-client-id` | `$SPOTIFY_CLIENT_ID` | Spotify API client ID. See [Spotify credentials](#spotify-credentials). |
229
+ | `--spotify-client-secret` | `$SPOTIFY_CLIENT_SECRET` | Spotify API client secret. |
226
230
 
227
231
  ```bash
228
232
  trackfetch songs.txt -o ~/Music/RoadTrip # save to a specific folder
229
233
  trackfetch songs.txt -o /media/usb --title-only # short names for a car stereo
230
234
  trackfetch big-list.txt --delay 3 # go easy on rate limits
231
235
  trackfetch songs.txt --format opus # original YouTube audio, no re-encode
236
+ trackfetch "Daft Punk - One More Time" # one track, no file needed
237
+ trackfetch "A - B" "C - D" --track "E - F" # a few tracks in one go
232
238
  ```
233
239
 
240
+ ### Inline tracks
241
+
242
+ Skip the file and name the tracks right on the command line:
243
+
244
+ ```bash
245
+ trackfetch "Daft Punk - One More Time"
246
+ trackfetch "Daft Punk - One More Time" "Radiohead - Paranoid Android"
247
+ trackfetch --track "Daft Punk - One More Time" --track "Radiohead - Paranoid Android"
248
+ ```
249
+
250
+ A file and inline tracks can be mixed: `trackfetch songs.txt --track "Blur - Song 2"`.
251
+ The separator is the same ` - ` as in the file, and the first ` - ` splits the
252
+ artist from the title.
253
+
254
+ ### Configuration file
255
+
256
+ Put your defaults in an INI file and forget the flags:
257
+
258
+ ```ini
259
+ [trackfetch]
260
+ output = ~/Music/trackfetch
261
+ format = m4a
262
+ title_only = false
263
+ delay = 1.0
264
+ input = ~/songs.txt
265
+ spotify_client_id = your-client-id
266
+ spotify_client_secret = your-client-secret
267
+ ```
268
+
269
+ Every option above can be set there, under the `[trackfetch]` section. The
270
+ precedence is always the same:
271
+
272
+ 1. the command-line flag,
273
+ 2. the config file,
274
+ 3. the built-in default (the current directory for the output folder).
275
+
276
+ trackfetch looks for the config file in this order: the path given with
277
+ `--config`, the path in `$TRACKFETCH_CONFIG`, `trackfetch.ini` in the current
278
+ directory, `$XDG_CONFIG_HOME/trackfetch/trackfetch.ini` (usually
279
+ `~/.config/trackfetch/trackfetch.ini`) and `~/.trackfetch.ini`.
280
+
281
+ ### Spotify credentials
282
+
283
+ trackfetch needs a Spotify client ID and secret to read the metadata. Give
284
+ them in whichever way suits you:
285
+
286
+ ```bash
287
+ # in the config file
288
+ [trackfetch]
289
+ spotify_client_id = your-client-id
290
+ spotify_client_secret = your-client-secret
291
+ ```
292
+
293
+ ```bash
294
+ # in the environment
295
+ export SPOTIFY_CLIENT_ID='your-client-id'
296
+ export SPOTIFY_CLIENT_SECRET='your-client-secret'
297
+ ```
298
+
299
+ ```bash
300
+ # on the command line
301
+ trackfetch songs.txt --spotify-client-id your-id --spotify-client-secret your-secret
302
+ ```
303
+
304
+ The flag wins over the config file, and the config file wins over the
305
+ environment variable.
306
+
234
307
  ### Audio formats
235
308
 
236
309
  | Format | What you get | Plays on |
237
310
  | --- | --- | --- |
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 |
311
+ | `m4a` (default) | YouTube's **original** AAC audio when available, copied without re-encoding | Apple devices, iTunes, most players |
239
312
  | `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 |
313
+ | `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
314
 
242
315
  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
316
 
@@ -254,9 +327,9 @@ The separator is space, hyphen, space (` - `). Lines without it are skipped. See
254
327
  ### Output folder
255
328
 
256
329
  ```text
257
- ~/Music/trackfetch/
258
- ├── Daft Punk - One More Time.mp3
259
- ├── Radiohead - Paranoid Android.mp3
330
+ ./
331
+ ├── Daft Punk - One More Time.m4a
332
+ ├── Radiohead - Paranoid Android.m4a
260
333
  ├── done.txt ← finished songs, skipped on the next run
261
334
  └── failed.txt ← "Artist - Title | reason" for each song that failed
262
335
  ```
@@ -264,7 +337,7 @@ The separator is space, hyphen, space (` - `). Lines without it are skipped. See
264
337
  **Retry failures** by stripping the reasons from `failed.txt` and running it again:
265
338
 
266
339
  ```bash
267
- cut -d '|' -f 1 ~/Music/trackfetch/failed.txt > retry.txt
340
+ cut -d '|' -f 1 ./failed.txt > retry.txt
268
341
  trackfetch retry.txt
269
342
  ```
270
343
 
@@ -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,31 +182,104 @@ 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] [--track ARTIST - TITLE ...] [-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, or an inline `Artist - Title` track. Can also be set in the [config file](#configuration-file). |
191
+ | `--track` | — | Track to download inline, `Artist - Title`. Can be given several times. |
192
+ | `-o`, `--output` | current directory | Folder to save the files in. It's created if it doesn't exist. |
193
+ | `--config` | auto-detected | Path to a [config file](#configuration-file). |
194
+ | `-f`, `--format` | `m4a` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
195
+ | `--title-only` | off | Name files `Title.m4a` instead of `Artist - Title.m4a` |
194
196
  | `--delay` | `1.0` | Seconds to wait between songs. Raise it for long lists. |
197
+ | `--spotify-client-id` | `$SPOTIFY_CLIENT_ID` | Spotify API client ID. See [Spotify credentials](#spotify-credentials). |
198
+ | `--spotify-client-secret` | `$SPOTIFY_CLIENT_SECRET` | Spotify API client secret. |
195
199
 
196
200
  ```bash
197
201
  trackfetch songs.txt -o ~/Music/RoadTrip # save to a specific folder
198
202
  trackfetch songs.txt -o /media/usb --title-only # short names for a car stereo
199
203
  trackfetch big-list.txt --delay 3 # go easy on rate limits
200
204
  trackfetch songs.txt --format opus # original YouTube audio, no re-encode
205
+ trackfetch "Daft Punk - One More Time" # one track, no file needed
206
+ trackfetch "A - B" "C - D" --track "E - F" # a few tracks in one go
201
207
  ```
202
208
 
209
+ ### Inline tracks
210
+
211
+ Skip the file and name the tracks right on the command line:
212
+
213
+ ```bash
214
+ trackfetch "Daft Punk - One More Time"
215
+ trackfetch "Daft Punk - One More Time" "Radiohead - Paranoid Android"
216
+ trackfetch --track "Daft Punk - One More Time" --track "Radiohead - Paranoid Android"
217
+ ```
218
+
219
+ A file and inline tracks can be mixed: `trackfetch songs.txt --track "Blur - Song 2"`.
220
+ The separator is the same ` - ` as in the file, and the first ` - ` splits the
221
+ artist from the title.
222
+
223
+ ### Configuration file
224
+
225
+ Put your defaults in an INI file and forget the flags:
226
+
227
+ ```ini
228
+ [trackfetch]
229
+ output = ~/Music/trackfetch
230
+ format = m4a
231
+ title_only = false
232
+ delay = 1.0
233
+ input = ~/songs.txt
234
+ spotify_client_id = your-client-id
235
+ spotify_client_secret = your-client-secret
236
+ ```
237
+
238
+ Every option above can be set there, under the `[trackfetch]` section. The
239
+ precedence is always the same:
240
+
241
+ 1. the command-line flag,
242
+ 2. the config file,
243
+ 3. the built-in default (the current directory for the output folder).
244
+
245
+ trackfetch looks for the config file in this order: the path given with
246
+ `--config`, the path in `$TRACKFETCH_CONFIG`, `trackfetch.ini` in the current
247
+ directory, `$XDG_CONFIG_HOME/trackfetch/trackfetch.ini` (usually
248
+ `~/.config/trackfetch/trackfetch.ini`) and `~/.trackfetch.ini`.
249
+
250
+ ### Spotify credentials
251
+
252
+ trackfetch needs a Spotify client ID and secret to read the metadata. Give
253
+ them in whichever way suits you:
254
+
255
+ ```bash
256
+ # in the config file
257
+ [trackfetch]
258
+ spotify_client_id = your-client-id
259
+ spotify_client_secret = your-client-secret
260
+ ```
261
+
262
+ ```bash
263
+ # in the environment
264
+ export SPOTIFY_CLIENT_ID='your-client-id'
265
+ export SPOTIFY_CLIENT_SECRET='your-client-secret'
266
+ ```
267
+
268
+ ```bash
269
+ # on the command line
270
+ trackfetch songs.txt --spotify-client-id your-id --spotify-client-secret your-secret
271
+ ```
272
+
273
+ The flag wins over the config file, and the config file wins over the
274
+ environment variable.
275
+
203
276
  ### Audio formats
204
277
 
205
278
  | Format | What you get | Plays on |
206
279
  | --- | --- | --- |
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 |
280
+ | `m4a` (default) | YouTube's **original** AAC audio when available, copied without re-encoding | Apple devices, iTunes, most players |
208
281
  | `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 |
282
+ | `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
283
 
211
284
  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
285
 
@@ -223,9 +296,9 @@ The separator is space, hyphen, space (` - `). Lines without it are skipped. See
223
296
  ### Output folder
224
297
 
225
298
  ```text
226
- ~/Music/trackfetch/
227
- ├── Daft Punk - One More Time.mp3
228
- ├── Radiohead - Paranoid Android.mp3
299
+ ./
300
+ ├── Daft Punk - One More Time.m4a
301
+ ├── Radiohead - Paranoid Android.m4a
229
302
  ├── done.txt ← finished songs, skipped on the next run
230
303
  └── failed.txt ← "Artist - Title | reason" for each song that failed
231
304
  ```
@@ -233,7 +306,7 @@ The separator is space, hyphen, space (` - `). Lines without it are skipped. See
233
306
  **Retry failures** by stripping the reasons from `failed.txt` and running it again:
234
307
 
235
308
  ```bash
236
- cut -d '|' -f 1 ~/Music/trackfetch/failed.txt > retry.txt
309
+ cut -d '|' -f 1 ./failed.txt > retry.txt
237
310
  trackfetch retry.txt
238
311
  ```
239
312
 
@@ -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.5"
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" 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