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.
- {trackfetch-2.1.0 → trackfetch-2.2.0}/PKG-INFO +69 -13
- {trackfetch-2.1.0 → trackfetch-2.2.0}/README.md +68 -12
- {trackfetch-2.1.0 → trackfetch-2.2.0}/pyproject.toml +1 -1
- trackfetch-2.2.0/tests/test_config.py +346 -0
- {trackfetch-2.1.0 → trackfetch-2.2.0}/tests/test_pipeline.py +5 -5
- {trackfetch-2.1.0 → trackfetch-2.2.0}/trackfetch.egg-info/PKG-INFO +69 -13
- {trackfetch-2.1.0 → trackfetch-2.2.0}/trackfetch.egg-info/SOURCES.txt +1 -0
- {trackfetch-2.1.0 → trackfetch-2.2.0}/trackfetch.py +255 -10
- {trackfetch-2.1.0 → trackfetch-2.2.0}/LICENSE +0 -0
- {trackfetch-2.1.0 → trackfetch-2.2.0}/setup.cfg +0 -0
- {trackfetch-2.1.0 → trackfetch-2.2.0}/tests/test_spotify.py +0 -0
- {trackfetch-2.1.0 → trackfetch-2.2.0}/tests/test_tags_and_files.py +0 -0
- {trackfetch-2.1.0 → trackfetch-2.2.0}/tests/test_text.py +0 -0
- {trackfetch-2.1.0 → trackfetch-2.2.0}/tests/test_youtube.py +0 -0
- {trackfetch-2.1.0 → trackfetch-2.2.0}/trackfetch.egg-info/dependency_links.txt +0 -0
- {trackfetch-2.1.0 → trackfetch-2.2.0}/trackfetch.egg-info/entry_points.txt +0 -0
- {trackfetch-2.1.0 → trackfetch-2.2.0}/trackfetch.egg-info/requires.txt +0 -0
- {trackfetch-2.1.0 → trackfetch-2.2.0}/trackfetch.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trackfetch
|
|
3
|
-
Version: 2.
|
|
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
|
|
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` |
|
|
223
|
-
|
|
|
224
|
-
| `--
|
|
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
|
-
| `
|
|
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
|
-
| `
|
|
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
|
-
|
|
258
|
-
├── Daft Punk - One More Time.
|
|
259
|
-
├── Radiohead - Paranoid Android.
|
|
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
|
|
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
|
|
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` |
|
|
192
|
-
|
|
|
193
|
-
| `--
|
|
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
|
-
| `
|
|
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
|
-
| `
|
|
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
|
-
|
|
227
|
-
├── Daft Punk - One More Time.
|
|
228
|
-
├── Radiohead - Paranoid Android.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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"] == ["
|
|
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.
|
|
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
|
|
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` |
|
|
223
|
-
|
|
|
224
|
-
| `--
|
|
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
|
-
| `
|
|
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
|
-
| `
|
|
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
|
-
|
|
258
|
-
├── Daft Punk - One More Time.
|
|
259
|
-
├── Radiohead - Paranoid Android.
|
|
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
|
|
323
|
+
cut -d '|' -f 1 ./failed.txt > retry.txt
|
|
268
324
|
trackfetch retry.txt
|
|
269
325
|
```
|
|
270
326
|
|
|
@@ -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
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
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=
|
|
717
|
-
help=
|
|
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=
|
|
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=
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|