trackfetch 2.2.0__tar.gz → 2.2.6__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.2.0/trackfetch.egg-info → trackfetch-2.2.6}/PKG-INFO +27 -8
- {trackfetch-2.2.0 → trackfetch-2.2.6}/README.md +26 -7
- {trackfetch-2.2.0 → trackfetch-2.2.6}/pyproject.toml +1 -1
- {trackfetch-2.2.0 → trackfetch-2.2.6}/tests/test_config.py +1 -1
- trackfetch-2.2.6/tests/test_inline.py +208 -0
- {trackfetch-2.2.0 → trackfetch-2.2.6/trackfetch.egg-info}/PKG-INFO +27 -8
- {trackfetch-2.2.0 → trackfetch-2.2.6}/trackfetch.egg-info/SOURCES.txt +1 -0
- {trackfetch-2.2.0 → trackfetch-2.2.6}/trackfetch.py +154 -28
- {trackfetch-2.2.0 → trackfetch-2.2.6}/LICENSE +0 -0
- {trackfetch-2.2.0 → trackfetch-2.2.6}/setup.cfg +0 -0
- {trackfetch-2.2.0 → trackfetch-2.2.6}/tests/test_pipeline.py +0 -0
- {trackfetch-2.2.0 → trackfetch-2.2.6}/tests/test_spotify.py +0 -0
- {trackfetch-2.2.0 → trackfetch-2.2.6}/tests/test_tags_and_files.py +0 -0
- {trackfetch-2.2.0 → trackfetch-2.2.6}/tests/test_text.py +0 -0
- {trackfetch-2.2.0 → trackfetch-2.2.6}/tests/test_youtube.py +0 -0
- {trackfetch-2.2.0 → trackfetch-2.2.6}/trackfetch.egg-info/dependency_links.txt +0 -0
- {trackfetch-2.2.0 → trackfetch-2.2.6}/trackfetch.egg-info/entry_points.txt +0 -0
- {trackfetch-2.2.0 → trackfetch-2.2.6}/trackfetch.egg-info/requires.txt +0 -0
- {trackfetch-2.2.0 → trackfetch-2.2.6}/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.2.
|
|
3
|
+
Version: 2.2.6
|
|
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
|
|
@@ -213,12 +213,13 @@ trackfetch "My Playlist.txt" -o ~/Music/"My Playlist"
|
|
|
213
213
|
## Usage
|
|
214
214
|
|
|
215
215
|
```text
|
|
216
|
-
trackfetch [-h] [-o OUTPUT] [--config CONFIG] [-f {mp3,m4a,opus}] [--title-only] [--delay DELAY] [--spotify-client-id ID] [--spotify-client-secret SECRET] [
|
|
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
|
-
| `
|
|
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. |
|
|
222
223
|
| `-o`, `--output` | current directory | Folder to save the files in. It's created if it doesn't exist. |
|
|
223
224
|
| `--config` | auto-detected | Path to a [config file](#configuration-file). |
|
|
224
225
|
| `-f`, `--format` | `m4a` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
|
|
@@ -232,8 +233,24 @@ trackfetch songs.txt -o ~/Music/RoadTrip # save to a specific folder
|
|
|
232
233
|
trackfetch songs.txt -o /media/usb --title-only # short names for a car stereo
|
|
233
234
|
trackfetch big-list.txt --delay 3 # go easy on rate limits
|
|
234
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
|
|
235
238
|
```
|
|
236
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
|
+
|
|
237
254
|
### Configuration file
|
|
238
255
|
|
|
239
256
|
Put your defaults in an INI file and forget the flags:
|
|
@@ -249,8 +266,9 @@ spotify_client_id = your-client-id
|
|
|
249
266
|
spotify_client_secret = your-client-secret
|
|
250
267
|
```
|
|
251
268
|
|
|
252
|
-
Every option above can be set there, under the `[trackfetch]` section
|
|
253
|
-
|
|
269
|
+
Every option above can be set there, under the `[trackfetch]` section, except
|
|
270
|
+
`--track`, which only makes sense on the command line. The precedence is
|
|
271
|
+
always the same:
|
|
254
272
|
|
|
255
273
|
1. the command-line flag,
|
|
256
274
|
2. the config file,
|
|
@@ -258,8 +276,8 @@ precedence is always the same:
|
|
|
258
276
|
|
|
259
277
|
trackfetch looks for the config file in this order: the path given with
|
|
260
278
|
`--config`, the path in `$TRACKFETCH_CONFIG`, `trackfetch.ini` in the current
|
|
261
|
-
directory, `$XDG_CONFIG_HOME/trackfetch/trackfetch.ini
|
|
262
|
-
`~/.config/trackfetch/trackfetch.ini`
|
|
279
|
+
directory, `$XDG_CONFIG_HOME/trackfetch/trackfetch.ini`,
|
|
280
|
+
`~/.config/trackfetch/trackfetch.ini` and `~/.trackfetch.ini`.
|
|
263
281
|
|
|
264
282
|
### Spotify credentials
|
|
265
283
|
|
|
@@ -305,7 +323,8 @@ Daft Punk - One More Time
|
|
|
305
323
|
Sufjan Stevens - Mystery of Love - Remastered ← only the first " - " separates artist and title
|
|
306
324
|
```
|
|
307
325
|
|
|
308
|
-
The separator is space, hyphen, space (` - `). Lines without it are skipped
|
|
326
|
+
The separator is space, hyphen, space (` - `). Lines without it are skipped,
|
|
327
|
+
and trackfetch prints how many lines it skipped and which ones they were. See [`examples/songs.txt`](https://github.com/ByteMe6/trackfetch/blob/master/examples/songs.txt).
|
|
309
328
|
|
|
310
329
|
### Output folder
|
|
311
330
|
|
|
@@ -182,12 +182,13 @@ trackfetch "My Playlist.txt" -o ~/Music/"My Playlist"
|
|
|
182
182
|
## Usage
|
|
183
183
|
|
|
184
184
|
```text
|
|
185
|
-
trackfetch [-h] [-o OUTPUT] [--config CONFIG] [-f {mp3,m4a,opus}] [--title-only] [--delay DELAY] [--spotify-client-id ID] [--spotify-client-secret SECRET] [
|
|
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
|
-
| `
|
|
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. |
|
|
191
192
|
| `-o`, `--output` | current directory | Folder to save the files in. It's created if it doesn't exist. |
|
|
192
193
|
| `--config` | auto-detected | Path to a [config file](#configuration-file). |
|
|
193
194
|
| `-f`, `--format` | `m4a` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
|
|
@@ -201,8 +202,24 @@ trackfetch songs.txt -o ~/Music/RoadTrip # save to a specific folder
|
|
|
201
202
|
trackfetch songs.txt -o /media/usb --title-only # short names for a car stereo
|
|
202
203
|
trackfetch big-list.txt --delay 3 # go easy on rate limits
|
|
203
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
|
|
204
207
|
```
|
|
205
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
|
+
|
|
206
223
|
### Configuration file
|
|
207
224
|
|
|
208
225
|
Put your defaults in an INI file and forget the flags:
|
|
@@ -218,8 +235,9 @@ spotify_client_id = your-client-id
|
|
|
218
235
|
spotify_client_secret = your-client-secret
|
|
219
236
|
```
|
|
220
237
|
|
|
221
|
-
Every option above can be set there, under the `[trackfetch]` section
|
|
222
|
-
|
|
238
|
+
Every option above can be set there, under the `[trackfetch]` section, except
|
|
239
|
+
`--track`, which only makes sense on the command line. The precedence is
|
|
240
|
+
always the same:
|
|
223
241
|
|
|
224
242
|
1. the command-line flag,
|
|
225
243
|
2. the config file,
|
|
@@ -227,8 +245,8 @@ precedence is always the same:
|
|
|
227
245
|
|
|
228
246
|
trackfetch looks for the config file in this order: the path given with
|
|
229
247
|
`--config`, the path in `$TRACKFETCH_CONFIG`, `trackfetch.ini` in the current
|
|
230
|
-
directory, `$XDG_CONFIG_HOME/trackfetch/trackfetch.ini
|
|
231
|
-
`~/.config/trackfetch/trackfetch.ini`
|
|
248
|
+
directory, `$XDG_CONFIG_HOME/trackfetch/trackfetch.ini`,
|
|
249
|
+
`~/.config/trackfetch/trackfetch.ini` and `~/.trackfetch.ini`.
|
|
232
250
|
|
|
233
251
|
### Spotify credentials
|
|
234
252
|
|
|
@@ -274,7 +292,8 @@ Daft Punk - One More Time
|
|
|
274
292
|
Sufjan Stevens - Mystery of Love - Remastered ← only the first " - " separates artist and title
|
|
275
293
|
```
|
|
276
294
|
|
|
277
|
-
The separator is space, hyphen, space (` - `). Lines without it are skipped
|
|
295
|
+
The separator is space, hyphen, space (` - `). Lines without it are skipped,
|
|
296
|
+
and trackfetch prints how many lines it skipped and which ones they were. See [`examples/songs.txt`](https://github.com/ByteMe6/trackfetch/blob/master/examples/songs.txt).
|
|
278
297
|
|
|
279
298
|
### Output folder
|
|
280
299
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "trackfetch"
|
|
7
|
-
version = "2.2.
|
|
7
|
+
version = "2.2.6"
|
|
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"
|
|
@@ -261,7 +261,7 @@ class TestMainWithConfig:
|
|
|
261
261
|
run_cli(monkeypatch)
|
|
262
262
|
|
|
263
263
|
assert exc.value.code == 1
|
|
264
|
-
assert "No input
|
|
264
|
+
assert "No input" in capsys.readouterr().out
|
|
265
265
|
|
|
266
266
|
def test_input_can_come_from_the_config(self, pipeline, monkeypatch, tmp_path):
|
|
267
267
|
songs = tmp_path / "songs.txt"
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from conftest import FIXTURES, make_track
|
|
6
|
+
|
|
7
|
+
import trackfetch as m
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def pipeline(monkeypatch):
|
|
12
|
+
state = {
|
|
13
|
+
"track": make_track("One More Time", ["Daft Punk"], album="Discovery", track_number=1),
|
|
14
|
+
"video": {"title": "Daft Punk - One More Time (Official Audio)", "id": "abc"},
|
|
15
|
+
"searched": [],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
def fake_find_spotify(sp, artist, title):
|
|
19
|
+
state["searched"].append((artist, title))
|
|
20
|
+
return state["track"]
|
|
21
|
+
|
|
22
|
+
def fake_download_audio(url, temp_dir, audio_format="mp3"):
|
|
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
|
+
monkeypatch.setattr(m, "find_spotify_track", fake_find_spotify)
|
|
31
|
+
monkeypatch.setattr(m, "find_youtube_video", lambda a, t: state["video"])
|
|
32
|
+
monkeypatch.setattr(m, "download_audio", fake_download_audio)
|
|
33
|
+
monkeypatch.setattr(m, "download_cover", lambda url, dest: dest.write_bytes(b"x"))
|
|
34
|
+
monkeypatch.setattr(m, "get_spotify", lambda *a, **k: object())
|
|
35
|
+
monkeypatch.setattr(m.time, "sleep", lambda s: None)
|
|
36
|
+
return state
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_cli(monkeypatch, *args):
|
|
40
|
+
monkeypatch.setattr(sys, "argv", ["trackfetch", *map(str, args)])
|
|
41
|
+
m.main()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestInlineTracks:
|
|
45
|
+
def test_single_inline_track(self, pipeline, monkeypatch, tmp_path):
|
|
46
|
+
out = tmp_path / "o"
|
|
47
|
+
|
|
48
|
+
run_cli(monkeypatch, "Daft Punk - One More Time", "-o", out)
|
|
49
|
+
|
|
50
|
+
assert pipeline["searched"] == [("Daft Punk", "One More Time")]
|
|
51
|
+
assert (out / "Daft Punk - One More Time.m4a").exists()
|
|
52
|
+
|
|
53
|
+
def test_several_inline_tracks(self, pipeline, monkeypatch, tmp_path):
|
|
54
|
+
out = tmp_path / "o"
|
|
55
|
+
|
|
56
|
+
run_cli(
|
|
57
|
+
monkeypatch,
|
|
58
|
+
"Daft Punk - One More Time",
|
|
59
|
+
"Radiohead - Paranoid Android",
|
|
60
|
+
"-o",
|
|
61
|
+
out,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assert [artist for artist, _ in pipeline["searched"]] == [
|
|
65
|
+
"Daft Punk",
|
|
66
|
+
"Radiohead",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
def test_track_flag(self, pipeline, monkeypatch, tmp_path):
|
|
70
|
+
out = tmp_path / "o"
|
|
71
|
+
|
|
72
|
+
run_cli(
|
|
73
|
+
monkeypatch,
|
|
74
|
+
"--track",
|
|
75
|
+
"Daft Punk - One More Time",
|
|
76
|
+
"--track",
|
|
77
|
+
"Radiohead - Paranoid Android",
|
|
78
|
+
"-o",
|
|
79
|
+
out,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
assert len(pipeline["searched"]) == 2
|
|
83
|
+
|
|
84
|
+
def test_track_flag_with_several_values(self, pipeline, monkeypatch, tmp_path):
|
|
85
|
+
out = tmp_path / "o"
|
|
86
|
+
|
|
87
|
+
run_cli(
|
|
88
|
+
monkeypatch,
|
|
89
|
+
"--track",
|
|
90
|
+
"Daft Punk - One More Time",
|
|
91
|
+
"Radiohead - Paranoid Android",
|
|
92
|
+
"-o",
|
|
93
|
+
out,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
assert len(pipeline["searched"]) == 2
|
|
97
|
+
|
|
98
|
+
def test_file_and_inline_track_together(self, pipeline, monkeypatch, tmp_path):
|
|
99
|
+
songs = tmp_path / "songs.txt"
|
|
100
|
+
songs.write_text("Daft Punk - One More Time\n", encoding="utf-8")
|
|
101
|
+
out = tmp_path / "o"
|
|
102
|
+
|
|
103
|
+
run_cli(monkeypatch, songs, "--track", "Radiohead - Paranoid Android", "-o", out)
|
|
104
|
+
|
|
105
|
+
assert len(pipeline["searched"]) == 2
|
|
106
|
+
|
|
107
|
+
def test_inline_track_and_file_in_one_call(self, pipeline, monkeypatch, tmp_path):
|
|
108
|
+
songs = tmp_path / "songs.txt"
|
|
109
|
+
songs.write_text("Daft Punk - One More Time\n", encoding="utf-8")
|
|
110
|
+
out = tmp_path / "o"
|
|
111
|
+
|
|
112
|
+
run_cli(monkeypatch, "Radiohead - Paranoid Android", songs, "-o", out)
|
|
113
|
+
|
|
114
|
+
assert len(pipeline["searched"]) == 2
|
|
115
|
+
|
|
116
|
+
def test_missing_file_exits(self, pipeline, monkeypatch, tmp_path, capsys):
|
|
117
|
+
with pytest.raises(SystemExit) as exc:
|
|
118
|
+
run_cli(monkeypatch, "songs.txt", "-o", tmp_path / "o")
|
|
119
|
+
|
|
120
|
+
assert exc.value.code == 1
|
|
121
|
+
assert "Input file not found" in capsys.readouterr().out
|
|
122
|
+
|
|
123
|
+
def test_unparsable_track_flag_exits(self, pipeline, monkeypatch, tmp_path, capsys):
|
|
124
|
+
with pytest.raises(SystemExit) as exc:
|
|
125
|
+
run_cli(monkeypatch, "--track", "nonsense", "-o", tmp_path / "o")
|
|
126
|
+
|
|
127
|
+
assert exc.value.code == 1
|
|
128
|
+
assert "Could not parse track" in capsys.readouterr().out
|
|
129
|
+
|
|
130
|
+
def test_inline_track_without_artist_exits(self, pipeline, monkeypatch, tmp_path, capsys):
|
|
131
|
+
with pytest.raises(SystemExit) as exc:
|
|
132
|
+
run_cli(monkeypatch, " - One More Time", "-o", tmp_path / "o")
|
|
133
|
+
|
|
134
|
+
assert exc.value.code == 1
|
|
135
|
+
assert "Could not parse track" in capsys.readouterr().out
|
|
136
|
+
|
|
137
|
+
def test_inline_track_without_title_exits(self, pipeline, monkeypatch, tmp_path, capsys):
|
|
138
|
+
with pytest.raises(SystemExit) as exc:
|
|
139
|
+
run_cli(monkeypatch, "Daft Punk - ", "-o", tmp_path / "o")
|
|
140
|
+
|
|
141
|
+
assert exc.value.code == 1
|
|
142
|
+
assert "Could not parse track" in capsys.readouterr().out
|
|
143
|
+
|
|
144
|
+
def test_wrong_separator_hints_at_the_track_format(
|
|
145
|
+
self, pipeline, monkeypatch, tmp_path, capsys
|
|
146
|
+
):
|
|
147
|
+
with pytest.raises(SystemExit) as exc:
|
|
148
|
+
run_cli(monkeypatch, "Daft Punk -One More Time", "-o", tmp_path / "o")
|
|
149
|
+
|
|
150
|
+
assert exc.value.code == 1
|
|
151
|
+
assert "Artist - Title" in capsys.readouterr().out
|
|
152
|
+
|
|
153
|
+
def test_unparsable_lines_in_a_file_are_reported(
|
|
154
|
+
self, pipeline, monkeypatch, tmp_path, capsys
|
|
155
|
+
):
|
|
156
|
+
songs = tmp_path / "songs.txt"
|
|
157
|
+
songs.write_text(
|
|
158
|
+
"Daft Punk -One More Time\nRadiohead - Paranoid Android\n",
|
|
159
|
+
encoding="utf-8",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
run_cli(monkeypatch, songs, "-o", tmp_path / "o")
|
|
163
|
+
log = capsys.readouterr().out
|
|
164
|
+
|
|
165
|
+
assert "WARNING: skipped 1 line(s)" in log
|
|
166
|
+
assert "Daft Punk -One More Time" in log
|
|
167
|
+
|
|
168
|
+
def test_inline_track_uses_the_output_rules(self, pipeline, monkeypatch, tmp_path):
|
|
169
|
+
out = tmp_path / "o"
|
|
170
|
+
config = tmp_path / "cfg.ini"
|
|
171
|
+
config.write_text(f"[trackfetch]\noutput = {out}\n", encoding="utf-8")
|
|
172
|
+
|
|
173
|
+
run_cli(monkeypatch, "Daft Punk - One More Time", "--config", config)
|
|
174
|
+
|
|
175
|
+
assert (out / "Daft Punk - One More Time.m4a").exists()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class TestCollectSources:
|
|
179
|
+
def test_config_input_is_a_file(self, tmp_path):
|
|
180
|
+
songs = tmp_path / "songs.txt"
|
|
181
|
+
songs.write_text("A - B\n", encoding="utf-8")
|
|
182
|
+
|
|
183
|
+
class Args:
|
|
184
|
+
inputs = []
|
|
185
|
+
track = None
|
|
186
|
+
|
|
187
|
+
sources = m.collect_sources(Args(), {"input": str(songs)})
|
|
188
|
+
|
|
189
|
+
assert sources == [songs]
|
|
190
|
+
|
|
191
|
+
def test_missing_config_input_raises(self, tmp_path):
|
|
192
|
+
class Args:
|
|
193
|
+
inputs = []
|
|
194
|
+
track = None
|
|
195
|
+
|
|
196
|
+
with pytest.raises(ValueError) as exc:
|
|
197
|
+
m.collect_sources(Args(), {"input": str(tmp_path / "nope.txt")})
|
|
198
|
+
|
|
199
|
+
assert "Input file not found" in str(exc.value)
|
|
200
|
+
|
|
201
|
+
def test_read_song_file_skips_comments(self, tmp_path):
|
|
202
|
+
songs = tmp_path / "songs.txt"
|
|
203
|
+
songs.write_text("# comment\n\nA - B\nbroken line\nC - D\n", encoding="utf-8")
|
|
204
|
+
|
|
205
|
+
parsed, skipped = m.read_song_file(songs)
|
|
206
|
+
|
|
207
|
+
assert parsed == [("A", "B"), ("C", "D")]
|
|
208
|
+
assert skipped == ["broken line"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trackfetch
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.6
|
|
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
|
|
@@ -213,12 +213,13 @@ trackfetch "My Playlist.txt" -o ~/Music/"My Playlist"
|
|
|
213
213
|
## Usage
|
|
214
214
|
|
|
215
215
|
```text
|
|
216
|
-
trackfetch [-h] [-o OUTPUT] [--config CONFIG] [-f {mp3,m4a,opus}] [--title-only] [--delay DELAY] [--spotify-client-id ID] [--spotify-client-secret SECRET] [
|
|
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
|
-
| `
|
|
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. |
|
|
222
223
|
| `-o`, `--output` | current directory | Folder to save the files in. It's created if it doesn't exist. |
|
|
223
224
|
| `--config` | auto-detected | Path to a [config file](#configuration-file). |
|
|
224
225
|
| `-f`, `--format` | `m4a` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
|
|
@@ -232,8 +233,24 @@ trackfetch songs.txt -o ~/Music/RoadTrip # save to a specific folder
|
|
|
232
233
|
trackfetch songs.txt -o /media/usb --title-only # short names for a car stereo
|
|
233
234
|
trackfetch big-list.txt --delay 3 # go easy on rate limits
|
|
234
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
|
|
235
238
|
```
|
|
236
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
|
+
|
|
237
254
|
### Configuration file
|
|
238
255
|
|
|
239
256
|
Put your defaults in an INI file and forget the flags:
|
|
@@ -249,8 +266,9 @@ spotify_client_id = your-client-id
|
|
|
249
266
|
spotify_client_secret = your-client-secret
|
|
250
267
|
```
|
|
251
268
|
|
|
252
|
-
Every option above can be set there, under the `[trackfetch]` section
|
|
253
|
-
|
|
269
|
+
Every option above can be set there, under the `[trackfetch]` section, except
|
|
270
|
+
`--track`, which only makes sense on the command line. The precedence is
|
|
271
|
+
always the same:
|
|
254
272
|
|
|
255
273
|
1. the command-line flag,
|
|
256
274
|
2. the config file,
|
|
@@ -258,8 +276,8 @@ precedence is always the same:
|
|
|
258
276
|
|
|
259
277
|
trackfetch looks for the config file in this order: the path given with
|
|
260
278
|
`--config`, the path in `$TRACKFETCH_CONFIG`, `trackfetch.ini` in the current
|
|
261
|
-
directory, `$XDG_CONFIG_HOME/trackfetch/trackfetch.ini
|
|
262
|
-
`~/.config/trackfetch/trackfetch.ini`
|
|
279
|
+
directory, `$XDG_CONFIG_HOME/trackfetch/trackfetch.ini`,
|
|
280
|
+
`~/.config/trackfetch/trackfetch.ini` and `~/.trackfetch.ini`.
|
|
263
281
|
|
|
264
282
|
### Spotify credentials
|
|
265
283
|
|
|
@@ -305,7 +323,8 @@ Daft Punk - One More Time
|
|
|
305
323
|
Sufjan Stevens - Mystery of Love - Remastered ← only the first " - " separates artist and title
|
|
306
324
|
```
|
|
307
325
|
|
|
308
|
-
The separator is space, hyphen, space (` - `). Lines without it are skipped
|
|
326
|
+
The separator is space, hyphen, space (` - `). Lines without it are skipped,
|
|
327
|
+
and trackfetch prints how many lines it skipped and which ones they were. See [`examples/songs.txt`](https://github.com/ByteMe6/trackfetch/blob/master/examples/songs.txt).
|
|
309
328
|
|
|
310
329
|
### Output folder
|
|
311
330
|
|
|
@@ -226,7 +226,7 @@ def download_cover(url: str, destination: Path):
|
|
|
226
226
|
url,
|
|
227
227
|
timeout=30,
|
|
228
228
|
headers={
|
|
229
|
-
"User-Agent": "trackfetch/2.2"
|
|
229
|
+
"User-Agent": "trackfetch/2.2.6"
|
|
230
230
|
},
|
|
231
231
|
)
|
|
232
232
|
|
|
@@ -846,7 +846,7 @@ def apply_config(args, parser, settings):
|
|
|
846
846
|
for action in parser._actions:
|
|
847
847
|
dest = action.dest
|
|
848
848
|
|
|
849
|
-
if dest in ("help", "version", "config"):
|
|
849
|
+
if dest in ("help", "version", "config", "inputs", "track"):
|
|
850
850
|
continue
|
|
851
851
|
|
|
852
852
|
if getattr(args, dest, None) is not None:
|
|
@@ -874,19 +874,124 @@ def apply_config(args, parser, settings):
|
|
|
874
874
|
setattr(args, dest, value)
|
|
875
875
|
|
|
876
876
|
|
|
877
|
+
def read_song_file(path: Path):
|
|
878
|
+
"""Read Artist - Title pairs from a text file.
|
|
879
|
+
|
|
880
|
+
Returns a (songs, skipped) tuple, where skipped holds the lines that
|
|
881
|
+
could not be parsed.
|
|
882
|
+
"""
|
|
883
|
+
songs = []
|
|
884
|
+
skipped = []
|
|
885
|
+
|
|
886
|
+
for line in path.read_text(
|
|
887
|
+
encoding="utf-8",
|
|
888
|
+
errors="replace",
|
|
889
|
+
).splitlines():
|
|
890
|
+
|
|
891
|
+
line = line.strip()
|
|
892
|
+
|
|
893
|
+
if not line or line.startswith("#"):
|
|
894
|
+
continue
|
|
895
|
+
|
|
896
|
+
song = parse_song(line)
|
|
897
|
+
|
|
898
|
+
if song:
|
|
899
|
+
songs.append(song)
|
|
900
|
+
|
|
901
|
+
else:
|
|
902
|
+
skipped.append(line)
|
|
903
|
+
|
|
904
|
+
return songs, skipped
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def collect_sources(args, settings):
|
|
908
|
+
"""Build the ordered list of inputs: file paths and inline tracks.
|
|
909
|
+
|
|
910
|
+
Every item is either a Path to a text file or an inline
|
|
911
|
+
"Artist - Title" string.
|
|
912
|
+
"""
|
|
913
|
+
sources = []
|
|
914
|
+
|
|
915
|
+
config_input = config_str_value(settings, "input")
|
|
916
|
+
|
|
917
|
+
if config_input:
|
|
918
|
+
path = Path(config_input).expanduser()
|
|
919
|
+
|
|
920
|
+
if not path.is_file():
|
|
921
|
+
raise ValueError(f"Input file not found: {path}")
|
|
922
|
+
|
|
923
|
+
sources.append(path)
|
|
924
|
+
|
|
925
|
+
for item in args.inputs:
|
|
926
|
+
path = Path(item).expanduser()
|
|
927
|
+
|
|
928
|
+
if path.is_file():
|
|
929
|
+
sources.append(path)
|
|
930
|
+
|
|
931
|
+
elif " - " in item:
|
|
932
|
+
if not parse_song(item):
|
|
933
|
+
raise ValueError(
|
|
934
|
+
f"Could not parse track: {item} "
|
|
935
|
+
f"(expected 'Artist - Title')"
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
sources.append(item)
|
|
939
|
+
|
|
940
|
+
else:
|
|
941
|
+
raise ValueError(
|
|
942
|
+
f"Input file not found: {item} "
|
|
943
|
+
f"(if this is a track, use the 'Artist - Title' format)"
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
for group in args.track or []:
|
|
947
|
+
for spec in group:
|
|
948
|
+
if not parse_song(spec):
|
|
949
|
+
raise ValueError(
|
|
950
|
+
f"Could not parse track: {spec} "
|
|
951
|
+
f"(expected 'Artist - Title')"
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
sources.append(spec)
|
|
955
|
+
|
|
956
|
+
return sources
|
|
957
|
+
|
|
958
|
+
|
|
877
959
|
def main():
|
|
878
960
|
parser = argparse.ArgumentParser(
|
|
879
961
|
description=(
|
|
880
962
|
"Download music using Spotify metadata "
|
|
881
963
|
"and YouTube audio"
|
|
882
|
-
)
|
|
964
|
+
),
|
|
965
|
+
epilog=(
|
|
966
|
+
"A config file with the defaults can be used as well. It is "
|
|
967
|
+
"looked up in --config, $TRACKFETCH_CONFIG, ./trackfetch.ini, "
|
|
968
|
+
"$XDG_CONFIG_HOME/trackfetch/trackfetch.ini, "
|
|
969
|
+
"~/.config/trackfetch/trackfetch.ini and ~/.trackfetch.ini. "
|
|
970
|
+
"A command-line flag wins over the config file, and the config "
|
|
971
|
+
"file wins over the built-in default."
|
|
972
|
+
),
|
|
973
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
883
974
|
)
|
|
884
975
|
|
|
885
976
|
parser.add_argument(
|
|
886
|
-
"
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
help=
|
|
977
|
+
"inputs",
|
|
978
|
+
nargs="*",
|
|
979
|
+
metavar="INPUT",
|
|
980
|
+
help=(
|
|
981
|
+
"Text file with Artist - Title, "
|
|
982
|
+
"or an inline 'Artist - Title' track"
|
|
983
|
+
),
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
parser.add_argument(
|
|
987
|
+
"--track",
|
|
988
|
+
action="append",
|
|
989
|
+
nargs="+",
|
|
990
|
+
metavar="ARTIST - TITLE",
|
|
991
|
+
help=(
|
|
992
|
+
"Track to download inline, "
|
|
993
|
+
"can be given several times"
|
|
994
|
+
),
|
|
890
995
|
)
|
|
891
996
|
|
|
892
997
|
parser.add_argument(
|
|
@@ -905,7 +1010,10 @@ def main():
|
|
|
905
1010
|
"--config",
|
|
906
1011
|
type=Path,
|
|
907
1012
|
default=None,
|
|
908
|
-
help=
|
|
1013
|
+
help=(
|
|
1014
|
+
"Path to a config file "
|
|
1015
|
+
"(default: auto-detected, see below)"
|
|
1016
|
+
),
|
|
909
1017
|
)
|
|
910
1018
|
|
|
911
1019
|
parser.add_argument(
|
|
@@ -913,21 +1021,21 @@ def main():
|
|
|
913
1021
|
"--format",
|
|
914
1022
|
choices=list(AUDIO_FORMATS),
|
|
915
1023
|
default=None,
|
|
916
|
-
help="Audio format",
|
|
1024
|
+
help="Audio format (default: m4a)",
|
|
917
1025
|
)
|
|
918
1026
|
|
|
919
1027
|
parser.add_argument(
|
|
920
1028
|
"--title-only",
|
|
921
1029
|
action="store_true",
|
|
922
1030
|
default=None,
|
|
923
|
-
help="Use only title in filename",
|
|
1031
|
+
help="Use only title in filename (default: off)",
|
|
924
1032
|
)
|
|
925
1033
|
|
|
926
1034
|
parser.add_argument(
|
|
927
1035
|
"--delay",
|
|
928
1036
|
type=float,
|
|
929
1037
|
default=None,
|
|
930
|
-
help="Delay between songs in seconds",
|
|
1038
|
+
help="Delay between songs in seconds (default: 1.0)",
|
|
931
1039
|
)
|
|
932
1040
|
|
|
933
1041
|
parser.add_argument(
|
|
@@ -974,15 +1082,19 @@ def main():
|
|
|
974
1082
|
if args.delay is None:
|
|
975
1083
|
args.delay = DEFAULT_DELAY
|
|
976
1084
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
)
|
|
1085
|
+
try:
|
|
1086
|
+
sources = collect_sources(args, settings)
|
|
1087
|
+
|
|
1088
|
+
except ValueError as error:
|
|
1089
|
+
print(f"ERROR: {error}")
|
|
982
1090
|
sys.exit(1)
|
|
983
1091
|
|
|
984
|
-
if not
|
|
985
|
-
print(
|
|
1092
|
+
if not sources:
|
|
1093
|
+
print(
|
|
1094
|
+
"ERROR: No input. "
|
|
1095
|
+
"Pass a file or an inline 'Artist - Title' track, "
|
|
1096
|
+
"or set 'input' in the config file."
|
|
1097
|
+
)
|
|
986
1098
|
sys.exit(1)
|
|
987
1099
|
|
|
988
1100
|
args.output.mkdir(
|
|
@@ -994,27 +1106,41 @@ def main():
|
|
|
994
1106
|
failed_file = args.output / "failed.txt"
|
|
995
1107
|
|
|
996
1108
|
songs = []
|
|
1109
|
+
skipped = []
|
|
997
1110
|
|
|
998
|
-
for
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
).splitlines():
|
|
1111
|
+
for source in sources:
|
|
1112
|
+
if isinstance(source, Path):
|
|
1113
|
+
file_songs, file_skipped = read_song_file(source)
|
|
1002
1114
|
|
|
1003
|
-
|
|
1115
|
+
songs.extend(file_songs)
|
|
1004
1116
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1117
|
+
skipped.extend(
|
|
1118
|
+
(source, line) for line in file_skipped
|
|
1119
|
+
)
|
|
1007
1120
|
|
|
1008
|
-
|
|
1121
|
+
else:
|
|
1122
|
+
song = parse_song(source)
|
|
1009
1123
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1124
|
+
if song:
|
|
1125
|
+
songs.append(song)
|
|
1012
1126
|
|
|
1013
1127
|
done = read_done(done_file)
|
|
1014
1128
|
|
|
1015
1129
|
print(f"Found {len(songs)} songs")
|
|
1016
1130
|
print(f"Already completed: {len(done)}")
|
|
1017
1131
|
|
|
1132
|
+
if skipped:
|
|
1133
|
+
print(
|
|
1134
|
+
f"WARNING: skipped {len(skipped)} line(s) "
|
|
1135
|
+
f"that are not 'Artist - Title':"
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
for path, line in skipped[:5]:
|
|
1139
|
+
print(f" {path}: {line}")
|
|
1140
|
+
|
|
1141
|
+
if len(skipped) > 5:
|
|
1142
|
+
print(f" ... and {len(skipped) - 5} more")
|
|
1143
|
+
|
|
1018
1144
|
if config_path:
|
|
1019
1145
|
print(f"Config: {config_path}")
|
|
1020
1146
|
|
|
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
|
|
File without changes
|