trackfetch 2.2.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.2.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
@@ -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] [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. Can also be set in the [config file](#configuration-file). |
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:
@@ -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] [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. Can also be set in the [config file](#configuration-file). |
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:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trackfetch"
7
- version = "2.2.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"
@@ -261,7 +261,7 @@ class TestMainWithConfig:
261
261
  run_cli(monkeypatch)
262
262
 
263
263
  assert exc.value.code == 1
264
- assert "No input file" in capsys.readouterr().out
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,167 @@
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_uses_the_output_rules(self, pipeline, monkeypatch, tmp_path):
131
+ out = tmp_path / "o"
132
+ config = tmp_path / "cfg.ini"
133
+ config.write_text(f"[trackfetch]\noutput = {out}\n", encoding="utf-8")
134
+
135
+ run_cli(monkeypatch, "Daft Punk - One More Time", "--config", config)
136
+
137
+ assert (out / "Daft Punk - One More Time.m4a").exists()
138
+
139
+
140
+ class TestCollectSources:
141
+ def test_config_input_is_a_file(self, tmp_path):
142
+ songs = tmp_path / "songs.txt"
143
+ songs.write_text("A - B\n", encoding="utf-8")
144
+
145
+ class Args:
146
+ inputs = []
147
+ track = None
148
+
149
+ sources = m.collect_sources(Args(), {"input": str(songs)})
150
+
151
+ assert sources == [songs]
152
+
153
+ def test_missing_config_input_raises(self, tmp_path):
154
+ class Args:
155
+ inputs = []
156
+ track = None
157
+
158
+ with pytest.raises(ValueError) as exc:
159
+ m.collect_sources(Args(), {"input": str(tmp_path / "nope.txt")})
160
+
161
+ assert "Input file not found" in str(exc.value)
162
+
163
+ def test_read_song_file_skips_comments(self, tmp_path):
164
+ songs = tmp_path / "songs.txt"
165
+ songs.write_text("# comment\n\nA - B\nbroken line\nC - D\n", encoding="utf-8")
166
+
167
+ assert m.read_song_file(songs) == [("A", "B"), ("C", "D")]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trackfetch
3
- Version: 2.2.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
@@ -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] [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. Can also be set in the [config file](#configuration-file). |
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:
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  trackfetch.py
5
5
  tests/test_config.py
6
+ tests/test_inline.py
6
7
  tests/test_pipeline.py
7
8
  tests/test_spotify.py
8
9
  tests/test_tags_and_files.py
@@ -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.5"
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,6 +874,71 @@ 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
+ songs = []
880
+
881
+ for line in path.read_text(
882
+ encoding="utf-8",
883
+ errors="replace",
884
+ ).splitlines():
885
+
886
+ line = line.strip()
887
+
888
+ if not line or line.startswith("#"):
889
+ continue
890
+
891
+ song = parse_song(line)
892
+
893
+ if song:
894
+ songs.append(song)
895
+
896
+ return songs
897
+
898
+
899
+ def collect_sources(args, settings):
900
+ """Build the ordered list of inputs: file paths and inline tracks.
901
+
902
+ Every item is either a Path to a text file or an inline
903
+ "Artist - Title" string.
904
+ """
905
+ sources = []
906
+
907
+ config_input = config_str_value(settings, "input")
908
+
909
+ if config_input:
910
+ path = Path(config_input).expanduser()
911
+
912
+ if not path.is_file():
913
+ raise ValueError(f"Input file not found: {path}")
914
+
915
+ sources.append(path)
916
+
917
+ for item in args.inputs:
918
+ path = Path(item).expanduser()
919
+
920
+ if path.is_file():
921
+ sources.append(path)
922
+
923
+ elif " - " in item:
924
+ sources.append(item)
925
+
926
+ else:
927
+ raise ValueError(f"Input file not found: {item}")
928
+
929
+ for group in args.track or []:
930
+ for spec in group:
931
+ if not parse_song(spec):
932
+ raise ValueError(
933
+ f"Could not parse track: {spec} "
934
+ f"(expected 'Artist - Title')"
935
+ )
936
+
937
+ sources.append(spec)
938
+
939
+ return sources
940
+
941
+
877
942
  def main():
878
943
  parser = argparse.ArgumentParser(
879
944
  description=(
@@ -883,10 +948,24 @@ def main():
883
948
  )
884
949
 
885
950
  parser.add_argument(
886
- "input",
887
- type=Path,
888
- nargs="?",
889
- help="Text file with Artist - Title",
951
+ "inputs",
952
+ nargs="*",
953
+ metavar="INPUT",
954
+ help=(
955
+ "Text file with Artist - Title, "
956
+ "or an inline 'Artist - Title' track"
957
+ ),
958
+ )
959
+
960
+ parser.add_argument(
961
+ "--track",
962
+ action="append",
963
+ nargs="+",
964
+ metavar="ARTIST - TITLE",
965
+ help=(
966
+ "Track to download inline, "
967
+ "can be given several times"
968
+ ),
890
969
  )
891
970
 
892
971
  parser.add_argument(
@@ -974,15 +1053,19 @@ def main():
974
1053
  if args.delay is None:
975
1054
  args.delay = DEFAULT_DELAY
976
1055
 
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
- )
1056
+ try:
1057
+ sources = collect_sources(args, settings)
1058
+
1059
+ except ValueError as error:
1060
+ print(f"ERROR: {error}")
982
1061
  sys.exit(1)
983
1062
 
984
- if not args.input.exists():
985
- print(f"ERROR: Input file not found: {args.input}")
1063
+ if not sources:
1064
+ print(
1065
+ "ERROR: No input. "
1066
+ "Pass a file or an inline 'Artist - Title' track, "
1067
+ "or set 'input' in the config file."
1068
+ )
986
1069
  sys.exit(1)
987
1070
 
988
1071
  args.output.mkdir(
@@ -995,20 +1078,15 @@ def main():
995
1078
 
996
1079
  songs = []
997
1080
 
998
- for line in args.input.read_text(
999
- encoding="utf-8",
1000
- errors="replace",
1001
- ).splitlines():
1002
-
1003
- line = line.strip()
1081
+ for source in sources:
1082
+ if isinstance(source, Path):
1083
+ songs.extend(read_song_file(source))
1004
1084
 
1005
- if not line or line.startswith("#"):
1006
- continue
1007
-
1008
- song = parse_song(line)
1085
+ else:
1086
+ song = parse_song(source)
1009
1087
 
1010
- if song:
1011
- songs.append(song)
1088
+ if song:
1089
+ songs.append(song)
1012
1090
 
1013
1091
  done = read_done(done_file)
1014
1092
 
File without changes
File without changes