trackfetch 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trackfetch
3
- Version: 2.0.0
3
+ Version: 2.1.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
@@ -30,20 +30,21 @@ Requires-Dist: pytest-cov>=5; extra == "test"
30
30
  Dynamic: license-file
31
31
 
32
32
  <picture>
33
- <source media="(prefers-color-scheme: dark)" srcset=".github/assets/banner-dark.svg">
34
- <img alt="trackfetch: a text file of songs in, tagged MP3s with cover art out" src=".github/assets/banner-light.svg" width="100%">
33
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ByteMe6/trackfetch/master/.github/assets/banner-dark.svg">
34
+ <img alt="trackfetch: a text file of songs in, tagged MP3s with cover art out" src="https://raw.githubusercontent.com/ByteMe6/trackfetch/master/.github/assets/banner-light.svg" width="100%">
35
35
  </picture>
36
36
 
37
37
  <p align="center">
38
38
  <a href="https://github.com/ByteMe6/trackfetch/releases/latest"><img alt="Latest release" src="https://img.shields.io/github/v/release/ByteMe6/trackfetch?style=flat-square&label=release&labelColor=15122B&color=FFB547"></a>
39
+ <a href="https://pypi.org/project/trackfetch/"><img alt="PyPI" src="https://img.shields.io/pypi/v/trackfetch?style=flat-square&label=pypi&labelColor=15122B&color=FFB547"></a>
39
40
  <a href="https://github.com/ByteMe6/trackfetch/actions/workflows/release.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/ByteMe6/trackfetch/release.yml?branch=master&style=flat-square&label=build&labelColor=15122B"></a>
40
41
  <a href="#install"><img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-FFB547?style=flat-square&labelColor=15122B"></a>
41
42
  <a href="#standalone-binary"><img alt="Linux, macOS and Windows on x86_64 and ARM64" src="https://img.shields.io/badge/linux%20%C2%B7%20macos%20%C2%B7%20windows-x86__64%20%2B%20arm64-FFB547?style=flat-square&labelColor=15122B"></a>
42
- <a href="LICENSE"><img alt="License: GPL-3.0-or-later" src="https://img.shields.io/badge/license-GPL--3.0--or--later-FFB547?style=flat-square&labelColor=15122B"></a>
43
+ <a href="https://github.com/ByteMe6/trackfetch/blob/master/LICENSE"><img alt="License: GPL-3.0-or-later" src="https://img.shields.io/badge/license-GPL--3.0--or--later-FFB547?style=flat-square&labelColor=15122B"></a>
43
44
  </p>
44
45
 
45
46
  <p align="center">
46
- <b>English</b> · <a href="README.uk.md">Українська</a> · <a href="README.ru.md">Русский</a>
47
+ <b>English</b> · <a href="https://github.com/ByteMe6/trackfetch/blob/master/README.uk.md">Українська</a> · <a href="https://github.com/ByteMe6/trackfetch/blob/master/README.ru.md">Русский</a>
47
48
  </p>
48
49
 
49
50
  <p align="center">
@@ -57,7 +58,7 @@ Dynamic: license-file
57
58
 
58
59
  <br>
59
60
 
60
- <img alt="Terminal recording: trackfetch skips two songs that are already downloaded, finds Daft Punk - One More Time on Spotify, scores five YouTube results, downloads the best one and saves a tagged MP3" src=".github/assets/demo.svg" width="100%">
61
+ <img alt="Terminal recording: trackfetch skips two songs that are already downloaded, finds Daft Punk - One More Time on Spotify, scores five YouTube results, downloads the best one and saves a tagged MP3" src="https://raw.githubusercontent.com/ByteMe6/trackfetch/master/.github/assets/demo.svg" width="100%">
61
62
 
62
63
  <br>
63
64
 
@@ -66,7 +67,7 @@ trackfetch reads a plain text file with one `Artist - Title` per line. For every
66
67
  - **Exact metadata.** The title, every credited artist, album, album artist, track number and release date come from Spotify, not from a YouTube video title.
67
68
  - **Album art.** The largest Spotify cover is embedded in every file.
68
69
  - **The right upload.** trackfetch scores five YouTube results per song. Official audio wins; covers, karaoke, nightcore, sped-up, slowed, remix and reaction videos lose.
69
- - **Best quality.** yt-dlp extracts the audio as the highest-quality VBR MP3.
70
+ - **Your choice of format.** MP3 for any player, or Opus and M4A that keep YouTube's original audio without re-encoding.
70
71
  - **Resumable.** Finished songs are logged in `done.txt` and skipped next time. Failures go to `failed.txt` with the reason.
71
72
  - **Plays everywhere.** Tags are written as ID3v2.3, which Windows Explorer, Apple Music, Android, car stereos and most other players read.
72
73
  - **Any alphabet.** Cyrillic and other non-Latin titles are matched correctly and kept in filenames. Characters that are illegal in filenames are replaced.
@@ -74,13 +75,32 @@ trackfetch reads a plain text file with one `Artist - Title` per line. For every
74
75
 
75
76
  ## Install
76
77
 
77
- trackfetch needs these tools on your `PATH`, plus a free Spotify API key ([set it up below](#spotify-credentials)):
78
+ Pick one. Homebrew and Nix also install yt-dlp, FFmpeg and Deno for you; with the other methods, install them yourself ([see below](#dependencies)). Every method needs a free Spotify API key ([set it up below](#spotify-credentials)).
78
79
 
79
- | Tool | Used for | Install |
80
- | --- | --- | --- |
81
- | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | Searching and downloading from YouTube | `pipx install yt-dlp` · `brew install yt-dlp` · `winget install yt-dlp` |
82
- | [FFmpeg](https://ffmpeg.org/) | Converting the audio to MP3 | `sudo apt install ffmpeg` · `brew install ffmpeg` · `winget install ffmpeg` |
83
- | [Deno](https://deno.com/) | JavaScript runtime that yt-dlp needs for YouTube | `curl -fsSL https://deno.land/install.sh \| sh` · `brew install deno` · `winget install DenoLand.Deno` |
80
+ ### Homebrew
81
+
82
+ macOS and Linux:
83
+
84
+ ```bash
85
+ brew install ByteMe6/tap/trackfetch
86
+ ```
87
+
88
+ On Intel Macs, Homebrew builds the dependencies from source, so the first install takes a while.
89
+
90
+ ### Nix
91
+
92
+ Linux and Apple Silicon Macs, with flakes enabled:
93
+
94
+ ```bash
95
+ nix run github:ByteMe6/trackfetch -- songs.txt # run without installing
96
+ nix profile install github:ByteMe6/trackfetch # install
97
+ ```
98
+
99
+ ### pipx
100
+
101
+ ```bash
102
+ pipx install trackfetch
103
+ ```
84
104
 
85
105
  ### Standalone binary
86
106
 
@@ -101,12 +121,6 @@ sudo mv trackfetch-linux-x86_64 /usr/local/bin/trackfetch
101
121
 
102
122
  On macOS, if Gatekeeper blocks the unsigned binary, run `xattr -d com.apple.quarantine trackfetch-macos-*` once. Checksums are in `SHA256SUMS.txt` on each release.
103
123
 
104
- ### pipx
105
-
106
- ```bash
107
- pipx install git+https://github.com/ByteMe6/trackfetch.git
108
- ```
109
-
110
124
  ### From source
111
125
 
112
126
  ```bash
@@ -116,6 +130,16 @@ python -m venv .venv && source .venv/bin/activate
116
130
  pip install -e .
117
131
  ```
118
132
 
133
+ ### Dependencies
134
+
135
+ With pipx, a standalone binary or a source install, put these on your `PATH`:
136
+
137
+ | Tool | Used for | Install |
138
+ | --- | --- | --- |
139
+ | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | Searching and downloading from YouTube | `pipx install yt-dlp` · `brew install yt-dlp` · `winget install yt-dlp` |
140
+ | [FFmpeg](https://ffmpeg.org/) | Converting the audio to MP3 | `sudo apt install ffmpeg` · `brew install ffmpeg` · `winget install ffmpeg` |
141
+ | [Deno](https://deno.com/) | JavaScript runtime that yt-dlp needs for YouTube | `curl -fsSL https://deno.land/install.sh \| sh` · `brew install deno` · `winget install DenoLand.Deno` |
142
+
119
143
  ## Spotify credentials
120
144
 
121
145
  trackfetch uses Spotify's Client Credentials flow: no Spotify login, no access to your account.
@@ -189,13 +213,14 @@ trackfetch "My Playlist.txt" -o ~/Music/"My Playlist"
189
213
  ## Usage
190
214
 
191
215
  ```text
192
- trackfetch [-h] [-o OUTPUT] [--title-only] [--delay DELAY] input
216
+ trackfetch [-h] [-o OUTPUT] [-f {mp3,m4a,opus}] [--title-only] [--delay DELAY] input
193
217
  ```
194
218
 
195
219
  | Option | Default | What it does |
196
220
  | --- | --- | --- |
197
221
  | `input` | required | Text file with one `Artist - Title` per line |
198
- | `-o`, `--output` | `~/Music/trackfetch` | Folder to save the MP3s in. It's created if it doesn't exist. |
222
+ | `-o`, `--output` | `~/Music/trackfetch` | Folder to save the files in. It's created if it doesn't exist. |
223
+ | `-f`, `--format` | `mp3` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
199
224
  | `--title-only` | off | Name files `Title.mp3` instead of `Artist - Title.mp3` |
200
225
  | `--delay` | `1.0` | Seconds to wait between songs. Raise it for long lists. |
201
226
 
@@ -203,8 +228,19 @@ trackfetch [-h] [-o OUTPUT] [--title-only] [--delay DELAY] input
203
228
  trackfetch songs.txt -o ~/Music/RoadTrip # save to a specific folder
204
229
  trackfetch songs.txt -o /media/usb --title-only # short names for a car stereo
205
230
  trackfetch big-list.txt --delay 3 # go easy on rate limits
231
+ trackfetch songs.txt --format opus # original YouTube audio, no re-encode
206
232
  ```
207
233
 
234
+ ### Audio formats
235
+
236
+ | Format | What you get | Plays on |
237
+ | --- | --- | --- |
238
+ | `mp3` (default) | YouTube's audio **re-encoded** to the best VBR MP3. A second lossy encode, so slightly below the source | everything, including old players and car stereos |
239
+ | `opus` | YouTube's **original** Opus audio, copied without re-encoding. The best quality you can get | most modern players, Android, browsers; not iTunes or older devices |
240
+ | `m4a` | YouTube's **original** AAC audio when available, copied without re-encoding | Apple devices, iTunes, most players |
241
+
242
+ 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
+
208
244
  ### Input file
209
245
 
210
246
  ```text
@@ -213,7 +249,7 @@ Daft Punk - One More Time
213
249
  Sufjan Stevens - Mystery of Love - Remastered ← only the first " - " separates artist and title
214
250
  ```
215
251
 
216
- The separator is space, hyphen, space (` - `). Lines without it are skipped. See [`examples/songs.txt`](examples/songs.txt).
252
+ The separator is space, hyphen, space (` - `). Lines without it are skipped. See [`examples/songs.txt`](https://github.com/ByteMe6/trackfetch/blob/master/examples/songs.txt).
217
253
 
218
254
  ### Output folder
219
255
 
@@ -236,7 +272,7 @@ trackfetch retry.txt
236
272
 
237
273
  ### Cheat sheet
238
274
 
239
- A [tldr](https://tldr.sh/) page is in [`docs/tldr/trackfetch.md`](docs/tldr/trackfetch.md). To use it with [tealdeer](https://github.com/tealdeer-rs/tealdeer), copy it into your custom pages folder as `trackfetch.page.md`.
275
+ A [tldr](https://tldr.sh/) page is in [`docs/tldr/trackfetch.md`](https://github.com/ByteMe6/trackfetch/blob/master/docs/tldr/trackfetch.md). To use it with [tealdeer](https://github.com/tealdeer-rs/tealdeer), copy it into your custom pages folder as `trackfetch.page.md`.
240
276
 
241
277
  ## How it works
242
278
 
@@ -313,11 +349,11 @@ pytest --cov=trackfetch
313
349
 
314
350
  The tests stub every network and subprocess call, so they run offline in under a second. To build a standalone binary yourself, run `pip install pyinstaller && pyinstaller trackfetch.spec`; it ends up in `dist/`.
315
351
 
316
- See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
352
+ See [CONTRIBUTING.md](https://github.com/ByteMe6/trackfetch/blob/master/CONTRIBUTING.md) before opening a pull request.
317
353
 
318
354
  ### Releasing
319
355
 
320
- Every push to `master` runs the tests on Linux, macOS and Windows. When `version` in [`pyproject.toml`](pyproject.toml) has no matching `vX.Y.Z` tag yet, the [Build & Release](.github/workflows/release.yml) workflow also builds all six binaries and publishes a release, using that version's section of [`CHANGELOG.md`](CHANGELOG.md) as the notes.
356
+ Every push to `master` runs the tests on Linux, macOS and Windows. When `version` in [`pyproject.toml`](https://github.com/ByteMe6/trackfetch/blob/master/pyproject.toml) has no matching `vX.Y.Z` tag yet, the [Build & Release](https://github.com/ByteMe6/trackfetch/blob/master/.github/workflows/release.yml) workflow also builds all six binaries and publishes a release, using that version's section of [`CHANGELOG.md`](https://github.com/ByteMe6/trackfetch/blob/master/CHANGELOG.md) as the notes.
321
357
 
322
358
  To release, move the changes under `[Unreleased]` in the changelog to a new `## [X.Y.Z] - YYYY-MM-DD` section, bump `version`, and push.
323
359
 
@@ -330,4 +366,4 @@ trackfetch is for personal use with content you have the right to download. You
330
366
 
331
367
  ## License
332
368
 
333
- trackfetch is free software, released under the [GNU General Public License v3.0 or later](LICENSE). You can use, study, change and share it. If you distribute a modified version, it must stay under the same license.
369
+ trackfetch is free software, released under the [GNU General Public License v3.0 or later](https://github.com/ByteMe6/trackfetch/blob/master/LICENSE). You can use, study, change and share it. If you distribute a modified version, it must stay under the same license.
@@ -1,18 +1,19 @@
1
1
  <picture>
2
- <source media="(prefers-color-scheme: dark)" srcset=".github/assets/banner-dark.svg">
3
- <img alt="trackfetch: a text file of songs in, tagged MP3s with cover art out" src=".github/assets/banner-light.svg" width="100%">
2
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ByteMe6/trackfetch/master/.github/assets/banner-dark.svg">
3
+ <img alt="trackfetch: a text file of songs in, tagged MP3s with cover art out" src="https://raw.githubusercontent.com/ByteMe6/trackfetch/master/.github/assets/banner-light.svg" width="100%">
4
4
  </picture>
5
5
 
6
6
  <p align="center">
7
7
  <a href="https://github.com/ByteMe6/trackfetch/releases/latest"><img alt="Latest release" src="https://img.shields.io/github/v/release/ByteMe6/trackfetch?style=flat-square&label=release&labelColor=15122B&color=FFB547"></a>
8
+ <a href="https://pypi.org/project/trackfetch/"><img alt="PyPI" src="https://img.shields.io/pypi/v/trackfetch?style=flat-square&label=pypi&labelColor=15122B&color=FFB547"></a>
8
9
  <a href="https://github.com/ByteMe6/trackfetch/actions/workflows/release.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/ByteMe6/trackfetch/release.yml?branch=master&style=flat-square&label=build&labelColor=15122B"></a>
9
10
  <a href="#install"><img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-FFB547?style=flat-square&labelColor=15122B"></a>
10
11
  <a href="#standalone-binary"><img alt="Linux, macOS and Windows on x86_64 and ARM64" src="https://img.shields.io/badge/linux%20%C2%B7%20macos%20%C2%B7%20windows-x86__64%20%2B%20arm64-FFB547?style=flat-square&labelColor=15122B"></a>
11
- <a href="LICENSE"><img alt="License: GPL-3.0-or-later" src="https://img.shields.io/badge/license-GPL--3.0--or--later-FFB547?style=flat-square&labelColor=15122B"></a>
12
+ <a href="https://github.com/ByteMe6/trackfetch/blob/master/LICENSE"><img alt="License: GPL-3.0-or-later" src="https://img.shields.io/badge/license-GPL--3.0--or--later-FFB547?style=flat-square&labelColor=15122B"></a>
12
13
  </p>
13
14
 
14
15
  <p align="center">
15
- <b>English</b> · <a href="README.uk.md">Українська</a> · <a href="README.ru.md">Русский</a>
16
+ <b>English</b> · <a href="https://github.com/ByteMe6/trackfetch/blob/master/README.uk.md">Українська</a> · <a href="https://github.com/ByteMe6/trackfetch/blob/master/README.ru.md">Русский</a>
16
17
  </p>
17
18
 
18
19
  <p align="center">
@@ -26,7 +27,7 @@
26
27
 
27
28
  <br>
28
29
 
29
- <img alt="Terminal recording: trackfetch skips two songs that are already downloaded, finds Daft Punk - One More Time on Spotify, scores five YouTube results, downloads the best one and saves a tagged MP3" src=".github/assets/demo.svg" width="100%">
30
+ <img alt="Terminal recording: trackfetch skips two songs that are already downloaded, finds Daft Punk - One More Time on Spotify, scores five YouTube results, downloads the best one and saves a tagged MP3" src="https://raw.githubusercontent.com/ByteMe6/trackfetch/master/.github/assets/demo.svg" width="100%">
30
31
 
31
32
  <br>
32
33
 
@@ -35,7 +36,7 @@ trackfetch reads a plain text file with one `Artist - Title` per line. For every
35
36
  - **Exact metadata.** The title, every credited artist, album, album artist, track number and release date come from Spotify, not from a YouTube video title.
36
37
  - **Album art.** The largest Spotify cover is embedded in every file.
37
38
  - **The right upload.** trackfetch scores five YouTube results per song. Official audio wins; covers, karaoke, nightcore, sped-up, slowed, remix and reaction videos lose.
38
- - **Best quality.** yt-dlp extracts the audio as the highest-quality VBR MP3.
39
+ - **Your choice of format.** MP3 for any player, or Opus and M4A that keep YouTube's original audio without re-encoding.
39
40
  - **Resumable.** Finished songs are logged in `done.txt` and skipped next time. Failures go to `failed.txt` with the reason.
40
41
  - **Plays everywhere.** Tags are written as ID3v2.3, which Windows Explorer, Apple Music, Android, car stereos and most other players read.
41
42
  - **Any alphabet.** Cyrillic and other non-Latin titles are matched correctly and kept in filenames. Characters that are illegal in filenames are replaced.
@@ -43,13 +44,32 @@ trackfetch reads a plain text file with one `Artist - Title` per line. For every
43
44
 
44
45
  ## Install
45
46
 
46
- trackfetch needs these tools on your `PATH`, plus a free Spotify API key ([set it up below](#spotify-credentials)):
47
+ Pick one. Homebrew and Nix also install yt-dlp, FFmpeg and Deno for you; with the other methods, install them yourself ([see below](#dependencies)). Every method needs a free Spotify API key ([set it up below](#spotify-credentials)).
47
48
 
48
- | Tool | Used for | Install |
49
- | --- | --- | --- |
50
- | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | Searching and downloading from YouTube | `pipx install yt-dlp` · `brew install yt-dlp` · `winget install yt-dlp` |
51
- | [FFmpeg](https://ffmpeg.org/) | Converting the audio to MP3 | `sudo apt install ffmpeg` · `brew install ffmpeg` · `winget install ffmpeg` |
52
- | [Deno](https://deno.com/) | JavaScript runtime that yt-dlp needs for YouTube | `curl -fsSL https://deno.land/install.sh \| sh` · `brew install deno` · `winget install DenoLand.Deno` |
49
+ ### Homebrew
50
+
51
+ macOS and Linux:
52
+
53
+ ```bash
54
+ brew install ByteMe6/tap/trackfetch
55
+ ```
56
+
57
+ On Intel Macs, Homebrew builds the dependencies from source, so the first install takes a while.
58
+
59
+ ### Nix
60
+
61
+ Linux and Apple Silicon Macs, with flakes enabled:
62
+
63
+ ```bash
64
+ nix run github:ByteMe6/trackfetch -- songs.txt # run without installing
65
+ nix profile install github:ByteMe6/trackfetch # install
66
+ ```
67
+
68
+ ### pipx
69
+
70
+ ```bash
71
+ pipx install trackfetch
72
+ ```
53
73
 
54
74
  ### Standalone binary
55
75
 
@@ -70,12 +90,6 @@ sudo mv trackfetch-linux-x86_64 /usr/local/bin/trackfetch
70
90
 
71
91
  On macOS, if Gatekeeper blocks the unsigned binary, run `xattr -d com.apple.quarantine trackfetch-macos-*` once. Checksums are in `SHA256SUMS.txt` on each release.
72
92
 
73
- ### pipx
74
-
75
- ```bash
76
- pipx install git+https://github.com/ByteMe6/trackfetch.git
77
- ```
78
-
79
93
  ### From source
80
94
 
81
95
  ```bash
@@ -85,6 +99,16 @@ python -m venv .venv && source .venv/bin/activate
85
99
  pip install -e .
86
100
  ```
87
101
 
102
+ ### Dependencies
103
+
104
+ With pipx, a standalone binary or a source install, put these on your `PATH`:
105
+
106
+ | Tool | Used for | Install |
107
+ | --- | --- | --- |
108
+ | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | Searching and downloading from YouTube | `pipx install yt-dlp` · `brew install yt-dlp` · `winget install yt-dlp` |
109
+ | [FFmpeg](https://ffmpeg.org/) | Converting the audio to MP3 | `sudo apt install ffmpeg` · `brew install ffmpeg` · `winget install ffmpeg` |
110
+ | [Deno](https://deno.com/) | JavaScript runtime that yt-dlp needs for YouTube | `curl -fsSL https://deno.land/install.sh \| sh` · `brew install deno` · `winget install DenoLand.Deno` |
111
+
88
112
  ## Spotify credentials
89
113
 
90
114
  trackfetch uses Spotify's Client Credentials flow: no Spotify login, no access to your account.
@@ -158,13 +182,14 @@ trackfetch "My Playlist.txt" -o ~/Music/"My Playlist"
158
182
  ## Usage
159
183
 
160
184
  ```text
161
- trackfetch [-h] [-o OUTPUT] [--title-only] [--delay DELAY] input
185
+ trackfetch [-h] [-o OUTPUT] [-f {mp3,m4a,opus}] [--title-only] [--delay DELAY] input
162
186
  ```
163
187
 
164
188
  | Option | Default | What it does |
165
189
  | --- | --- | --- |
166
190
  | `input` | required | Text file with one `Artist - Title` per line |
167
- | `-o`, `--output` | `~/Music/trackfetch` | Folder to save the MP3s in. It's created if it doesn't exist. |
191
+ | `-o`, `--output` | `~/Music/trackfetch` | Folder to save the files in. It's created if it doesn't exist. |
192
+ | `-f`, `--format` | `mp3` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
168
193
  | `--title-only` | off | Name files `Title.mp3` instead of `Artist - Title.mp3` |
169
194
  | `--delay` | `1.0` | Seconds to wait between songs. Raise it for long lists. |
170
195
 
@@ -172,8 +197,19 @@ trackfetch [-h] [-o OUTPUT] [--title-only] [--delay DELAY] input
172
197
  trackfetch songs.txt -o ~/Music/RoadTrip # save to a specific folder
173
198
  trackfetch songs.txt -o /media/usb --title-only # short names for a car stereo
174
199
  trackfetch big-list.txt --delay 3 # go easy on rate limits
200
+ trackfetch songs.txt --format opus # original YouTube audio, no re-encode
175
201
  ```
176
202
 
203
+ ### Audio formats
204
+
205
+ | Format | What you get | Plays on |
206
+ | --- | --- | --- |
207
+ | `mp3` (default) | YouTube's audio **re-encoded** to the best VBR MP3. A second lossy encode, so slightly below the source | everything, including old players and car stereos |
208
+ | `opus` | YouTube's **original** Opus audio, copied without re-encoding. The best quality you can get | most modern players, Android, browsers; not iTunes or older devices |
209
+ | `m4a` | YouTube's **original** AAC audio when available, copied without re-encoding | Apple devices, iTunes, most players |
210
+
211
+ 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
+
177
213
  ### Input file
178
214
 
179
215
  ```text
@@ -182,7 +218,7 @@ Daft Punk - One More Time
182
218
  Sufjan Stevens - Mystery of Love - Remastered ← only the first " - " separates artist and title
183
219
  ```
184
220
 
185
- The separator is space, hyphen, space (` - `). Lines without it are skipped. See [`examples/songs.txt`](examples/songs.txt).
221
+ The separator is space, hyphen, space (` - `). Lines without it are skipped. See [`examples/songs.txt`](https://github.com/ByteMe6/trackfetch/blob/master/examples/songs.txt).
186
222
 
187
223
  ### Output folder
188
224
 
@@ -205,7 +241,7 @@ trackfetch retry.txt
205
241
 
206
242
  ### Cheat sheet
207
243
 
208
- A [tldr](https://tldr.sh/) page is in [`docs/tldr/trackfetch.md`](docs/tldr/trackfetch.md). To use it with [tealdeer](https://github.com/tealdeer-rs/tealdeer), copy it into your custom pages folder as `trackfetch.page.md`.
244
+ A [tldr](https://tldr.sh/) page is in [`docs/tldr/trackfetch.md`](https://github.com/ByteMe6/trackfetch/blob/master/docs/tldr/trackfetch.md). To use it with [tealdeer](https://github.com/tealdeer-rs/tealdeer), copy it into your custom pages folder as `trackfetch.page.md`.
209
245
 
210
246
  ## How it works
211
247
 
@@ -282,11 +318,11 @@ pytest --cov=trackfetch
282
318
 
283
319
  The tests stub every network and subprocess call, so they run offline in under a second. To build a standalone binary yourself, run `pip install pyinstaller && pyinstaller trackfetch.spec`; it ends up in `dist/`.
284
320
 
285
- See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
321
+ See [CONTRIBUTING.md](https://github.com/ByteMe6/trackfetch/blob/master/CONTRIBUTING.md) before opening a pull request.
286
322
 
287
323
  ### Releasing
288
324
 
289
- Every push to `master` runs the tests on Linux, macOS and Windows. When `version` in [`pyproject.toml`](pyproject.toml) has no matching `vX.Y.Z` tag yet, the [Build & Release](.github/workflows/release.yml) workflow also builds all six binaries and publishes a release, using that version's section of [`CHANGELOG.md`](CHANGELOG.md) as the notes.
325
+ Every push to `master` runs the tests on Linux, macOS and Windows. When `version` in [`pyproject.toml`](https://github.com/ByteMe6/trackfetch/blob/master/pyproject.toml) has no matching `vX.Y.Z` tag yet, the [Build & Release](https://github.com/ByteMe6/trackfetch/blob/master/.github/workflows/release.yml) workflow also builds all six binaries and publishes a release, using that version's section of [`CHANGELOG.md`](https://github.com/ByteMe6/trackfetch/blob/master/CHANGELOG.md) as the notes.
290
326
 
291
327
  To release, move the changes under `[Unreleased]` in the changelog to a new `## [X.Y.Z] - YYYY-MM-DD` section, bump `version`, and push.
292
328
 
@@ -299,4 +335,4 @@ trackfetch is for personal use with content you have the right to download. You
299
335
 
300
336
  ## License
301
337
 
302
- trackfetch is free software, released under the [GNU General Public License v3.0 or later](LICENSE). You can use, study, change and share it. If you distribute a modified version, it must stay under the same license.
338
+ trackfetch is free software, released under the [GNU General Public License v3.0 or later](https://github.com/ByteMe6/trackfetch/blob/master/LICENSE). You can use, study, change and share it. If you distribute a modified version, it must stay under the same license.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trackfetch"
7
- version = "2.0.0"
7
+ version = "2.1.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"
@@ -1,8 +1,11 @@
1
+ import shutil
1
2
  import sys
2
3
 
3
4
  import pytest
4
- from conftest import make_track
5
+ from conftest import FIXTURES, make_track
5
6
  from mutagen.id3 import ID3
7
+ from mutagen.mp4 import MP4
8
+ from mutagen.oggopus import OggOpus
6
9
 
7
10
  import trackfetch as m
8
11
 
@@ -16,6 +19,7 @@ def pipeline(monkeypatch):
16
19
  "download_ok": True,
17
20
  "cover_ok": True,
18
21
  "downloaded_urls": [],
22
+ "formats": [],
19
23
  "searched": [],
20
24
  }
21
25
 
@@ -23,12 +27,16 @@ def pipeline(monkeypatch):
23
27
  state["searched"].append((artist, title))
24
28
  return state["track"]
25
29
 
26
- def fake_download_audio(url, temp_dir):
30
+ def fake_download_audio(url, temp_dir, audio_format="mp3"):
27
31
  state["downloaded_urls"].append(url)
32
+ state["formats"].append(audio_format)
28
33
  if not state["download_ok"]:
29
34
  return None
30
- path = temp_dir / "audio.mp3"
31
- path.write_bytes(b"\x00" * 64)
35
+ path = temp_dir / f"audio.{audio_format}"
36
+ if audio_format == "mp3":
37
+ path.write_bytes(b"\x00" * 64)
38
+ else:
39
+ shutil.copy(FIXTURES / f"silence.{audio_format}", path)
32
40
  return path
33
41
 
34
42
  def fake_download_cover(url, dest):
@@ -57,6 +65,23 @@ class TestProcessSong:
57
65
  assert tags.getall("APIC")
58
66
  assert pipeline["downloaded_urls"] == ["https://www.youtube.com/watch?v=abc"]
59
67
 
68
+ def test_opus(self, pipeline, tmp_path):
69
+ assert m.process_song(None, "a", "b", tmp_path, False, "opus") == (True, "downloaded")
70
+ tags = OggOpus(tmp_path / "Daft Punk - One More Time.opus")
71
+ assert tags["TITLE"] == ["One More Time"]
72
+ assert tags["METADATA_BLOCK_PICTURE"]
73
+ assert pipeline["formats"] == ["opus"]
74
+
75
+ def test_m4a(self, pipeline, tmp_path):
76
+ assert m.process_song(None, "a", "b", tmp_path, False, "m4a") == (True, "downloaded")
77
+ tags = MP4(tmp_path / "Daft Punk - One More Time.m4a")
78
+ assert tags["\xa9alb"] == ["Discovery"]
79
+ assert tags["covr"]
80
+
81
+ def test_existing_file_check_uses_format_extension(self, pipeline, tmp_path):
82
+ (tmp_path / "Daft Punk - One More Time.mp3").write_bytes(b"")
83
+ assert m.process_song(None, "a", "b", tmp_path, False, "opus") == (True, "downloaded")
84
+
60
85
  def test_title_only(self, pipeline, tmp_path):
61
86
  m.process_song(None, "a", "b", tmp_path, True)
62
87
  assert (tmp_path / "One More Time.mp3").exists()
@@ -185,3 +210,27 @@ class TestMain:
185
210
  log = capsys.readouterr().out
186
211
  assert "SKIPPED: 1" in log
187
212
  assert m.read_done(out / "done.txt") == {"Daft Punk - One More Time"}
213
+
214
+ def test_format_option(self, pipeline, monkeypatch, tmp_path):
215
+ songs = tmp_path / "songs.txt"
216
+ songs.write_text("Daft Punk - One More Time\n", encoding="utf-8")
217
+ out = tmp_path / "o"
218
+
219
+ run_cli(monkeypatch, songs, "-o", out, "--format", "opus")
220
+ assert (out / "Daft Punk - One More Time.opus").exists()
221
+ assert pipeline["formats"] == ["opus"]
222
+
223
+ def test_default_format_is_mp3(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
+
227
+ run_cli(monkeypatch, songs, "-o", tmp_path / "o")
228
+ assert pipeline["formats"] == ["mp3"]
229
+
230
+ def test_rejects_unknown_format(self, monkeypatch, tmp_path, capsys):
231
+ songs = tmp_path / "songs.txt"
232
+ songs.write_text("A - B\n", encoding="utf-8")
233
+ with pytest.raises(SystemExit) as exc:
234
+ run_cli(monkeypatch, songs, "--format", "flac")
235
+ assert exc.value.code == 2
236
+ assert "invalid choice" in capsys.readouterr().err
@@ -1,5 +1,12 @@
1
+ import base64
2
+ import shutil
3
+
1
4
  import pytest
5
+ from conftest import FIXTURES
6
+ from mutagen.flac import Picture
2
7
  from mutagen.id3 import ID3, TIT2
8
+ from mutagen.mp4 import MP4, MP4Cover
9
+ from mutagen.oggopus import OggOpus
3
10
 
4
11
  import trackfetch as m
5
12
 
@@ -56,6 +63,104 @@ class TestAddMetadata:
56
63
  assert ID3(mp3)["TIT2"].text == ["Мёртвый Анархист"]
57
64
 
58
65
 
66
+ @pytest.fixture
67
+ def cover(tmp_path):
68
+ path = tmp_path / "cover.jpg"
69
+ path.write_bytes(JPEG)
70
+ return path
71
+
72
+
73
+ @pytest.fixture
74
+ def opus(tmp_path):
75
+ path = tmp_path / "song.opus"
76
+ shutil.copy(FIXTURES / "silence.opus", path)
77
+ return path
78
+
79
+
80
+ @pytest.fixture
81
+ def m4a(tmp_path):
82
+ path = tmp_path / "song.m4a"
83
+ shutil.copy(FIXTURES / "silence.m4a", path)
84
+ return path
85
+
86
+
87
+ def opus_picture(tags):
88
+ return Picture(base64.b64decode(tags["METADATA_BLOCK_PICTURE"][0]))
89
+
90
+
91
+ class TestAddOpusMetadata:
92
+ def test_writes_all_fields(self, opus, cover, metadata):
93
+ m.add_metadata(opus, cover, metadata)
94
+
95
+ tags = OggOpus(opus)
96
+ assert tags["TITLE"] == ["One More Time"]
97
+ assert tags["ARTIST"] == ["Daft Punk"]
98
+ assert tags["ALBUM"] == ["Discovery"]
99
+ assert tags["ALBUMARTIST"] == ["Daft Punk"]
100
+ assert tags["TRACKNUMBER"] == ["1"]
101
+ assert tags["DATE"] == ["2001-03-12"]
102
+ picture = opus_picture(tags)
103
+ assert picture.data == JPEG
104
+ assert picture.type == 3
105
+ assert picture.mime == "image/jpeg"
106
+
107
+ def test_skips_empty_optional_fields(self, opus, tmp_path):
108
+ meta = m.get_track_metadata(None, "Artist", "Title")
109
+ meta["album_artist"] = ""
110
+ m.add_metadata(opus, tmp_path / "missing.jpg", meta)
111
+
112
+ tags = OggOpus(opus)
113
+ assert tags["TITLE"] == ["Title"]
114
+ for key in ("ALBUM", "ALBUMARTIST", "TRACKNUMBER", "DATE", "METADATA_BLOCK_PICTURE"):
115
+ assert key not in tags, key
116
+
117
+ def test_replaces_existing_tags(self, opus, cover, metadata):
118
+ m.add_metadata(opus, cover, {**metadata, "title": "Old title"})
119
+ m.add_metadata(opus, cover, metadata)
120
+
121
+ tags = OggOpus(opus)
122
+ assert tags["TITLE"] == ["One More Time"]
123
+ assert len(tags["METADATA_BLOCK_PICTURE"]) == 1
124
+
125
+ def test_audio_stays_readable(self, opus, cover, metadata):
126
+ m.add_metadata(opus, cover, metadata)
127
+ assert OggOpus(opus).info.length > 0
128
+
129
+
130
+ class TestAddM4aMetadata:
131
+ def test_writes_all_fields(self, m4a, cover, metadata):
132
+ m.add_metadata(m4a, cover, metadata)
133
+
134
+ tags = MP4(m4a)
135
+ assert tags["\xa9nam"] == ["One More Time"]
136
+ assert tags["\xa9ART"] == ["Daft Punk"]
137
+ assert tags["\xa9alb"] == ["Discovery"]
138
+ assert tags["aART"] == ["Daft Punk"]
139
+ assert tags["trkn"] == [(1, 0)]
140
+ assert tags["\xa9day"] == ["2001-03-12"]
141
+ assert bytes(tags["covr"][0]) == JPEG
142
+ assert tags["covr"][0].imageformat == MP4Cover.FORMAT_JPEG
143
+
144
+ def test_skips_empty_optional_fields(self, m4a, tmp_path):
145
+ meta = m.get_track_metadata(None, "Artist", "Title")
146
+ meta["album_artist"] = ""
147
+ m.add_metadata(m4a, tmp_path / "missing.jpg", meta)
148
+
149
+ tags = MP4(m4a)
150
+ assert tags["\xa9nam"] == ["Title"]
151
+ for key in ("\xa9alb", "aART", "trkn", "\xa9day", "covr"):
152
+ assert key not in tags, key
153
+
154
+ def test_replaces_existing_tags(self, m4a, cover, metadata):
155
+ m.add_metadata(m4a, cover, {**metadata, "title": "Old title", "track_number": 9})
156
+ m.add_metadata(m4a, cover, metadata)
157
+
158
+ tags = MP4(m4a)
159
+ assert tags["\xa9nam"] == ["One More Time"]
160
+ assert tags["trkn"] == [(1, 0)]
161
+ assert len(tags["covr"]) == 1
162
+
163
+
59
164
  class TestDownloadCover:
60
165
  def test_writes_bytes(self, monkeypatch, tmp_path):
61
166
  class Response:
@@ -74,3 +74,8 @@ class TestMakeFilename:
74
74
  def test_sanitized(self, metadata):
75
75
  metadata["artist"] = "AC/DC"
76
76
  assert m.make_filename(metadata, title_only=False) == "AC_DC - One More Time.mp3"
77
+
78
+ @pytest.mark.parametrize("audio_format", ["mp3", "m4a", "opus"])
79
+ def test_extension_follows_format(self, metadata, audio_format):
80
+ name = m.make_filename(metadata, title_only=True, audio_format=audio_format)
81
+ assert name == f"One More Time.{audio_format}"
@@ -100,3 +100,33 @@ class TestDownloadAudio:
100
100
  def test_no_output_file(self, monkeypatch, tmp_path):
101
101
  monkeypatch.setattr(m.subprocess, "run", lambda cmd: completed())
102
102
  assert m.download_audio("u", tmp_path) is None
103
+
104
+ @pytest.mark.parametrize(
105
+ ("audio_format", "selector"),
106
+ [
107
+ ("mp3", None),
108
+ ("m4a", "bestaudio[ext=m4a]/bestaudio"),
109
+ ("opus", "bestaudio[acodec=opus]/bestaudio"),
110
+ ],
111
+ )
112
+ def test_format_arguments(self, monkeypatch, tmp_path, audio_format, selector):
113
+ def fake_run(cmd):
114
+ assert cmd[cmd.index("--audio-format") + 1] == audio_format
115
+ if selector:
116
+ assert cmd[cmd.index("-f") + 1] == selector
117
+ else:
118
+ assert "-f" not in cmd
119
+ (tmp_path / f"audio.{audio_format}").write_bytes(b"x")
120
+ return completed()
121
+
122
+ monkeypatch.setattr(m.subprocess, "run", fake_run)
123
+ expected = tmp_path / f"audio.{audio_format}"
124
+ assert m.download_audio("u", tmp_path, audio_format) == expected
125
+
126
+ def test_ignores_files_in_other_formats(self, monkeypatch, tmp_path):
127
+ def fake_run(cmd):
128
+ (tmp_path / "audio.webm").write_bytes(b"x")
129
+ return completed()
130
+
131
+ monkeypatch.setattr(m.subprocess, "run", fake_run)
132
+ assert m.download_audio("u", tmp_path, "opus") is None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trackfetch
3
- Version: 2.0.0
3
+ Version: 2.1.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
@@ -30,20 +30,21 @@ Requires-Dist: pytest-cov>=5; extra == "test"
30
30
  Dynamic: license-file
31
31
 
32
32
  <picture>
33
- <source media="(prefers-color-scheme: dark)" srcset=".github/assets/banner-dark.svg">
34
- <img alt="trackfetch: a text file of songs in, tagged MP3s with cover art out" src=".github/assets/banner-light.svg" width="100%">
33
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ByteMe6/trackfetch/master/.github/assets/banner-dark.svg">
34
+ <img alt="trackfetch: a text file of songs in, tagged MP3s with cover art out" src="https://raw.githubusercontent.com/ByteMe6/trackfetch/master/.github/assets/banner-light.svg" width="100%">
35
35
  </picture>
36
36
 
37
37
  <p align="center">
38
38
  <a href="https://github.com/ByteMe6/trackfetch/releases/latest"><img alt="Latest release" src="https://img.shields.io/github/v/release/ByteMe6/trackfetch?style=flat-square&label=release&labelColor=15122B&color=FFB547"></a>
39
+ <a href="https://pypi.org/project/trackfetch/"><img alt="PyPI" src="https://img.shields.io/pypi/v/trackfetch?style=flat-square&label=pypi&labelColor=15122B&color=FFB547"></a>
39
40
  <a href="https://github.com/ByteMe6/trackfetch/actions/workflows/release.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/ByteMe6/trackfetch/release.yml?branch=master&style=flat-square&label=build&labelColor=15122B"></a>
40
41
  <a href="#install"><img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-FFB547?style=flat-square&labelColor=15122B"></a>
41
42
  <a href="#standalone-binary"><img alt="Linux, macOS and Windows on x86_64 and ARM64" src="https://img.shields.io/badge/linux%20%C2%B7%20macos%20%C2%B7%20windows-x86__64%20%2B%20arm64-FFB547?style=flat-square&labelColor=15122B"></a>
42
- <a href="LICENSE"><img alt="License: GPL-3.0-or-later" src="https://img.shields.io/badge/license-GPL--3.0--or--later-FFB547?style=flat-square&labelColor=15122B"></a>
43
+ <a href="https://github.com/ByteMe6/trackfetch/blob/master/LICENSE"><img alt="License: GPL-3.0-or-later" src="https://img.shields.io/badge/license-GPL--3.0--or--later-FFB547?style=flat-square&labelColor=15122B"></a>
43
44
  </p>
44
45
 
45
46
  <p align="center">
46
- <b>English</b> · <a href="README.uk.md">Українська</a> · <a href="README.ru.md">Русский</a>
47
+ <b>English</b> · <a href="https://github.com/ByteMe6/trackfetch/blob/master/README.uk.md">Українська</a> · <a href="https://github.com/ByteMe6/trackfetch/blob/master/README.ru.md">Русский</a>
47
48
  </p>
48
49
 
49
50
  <p align="center">
@@ -57,7 +58,7 @@ Dynamic: license-file
57
58
 
58
59
  <br>
59
60
 
60
- <img alt="Terminal recording: trackfetch skips two songs that are already downloaded, finds Daft Punk - One More Time on Spotify, scores five YouTube results, downloads the best one and saves a tagged MP3" src=".github/assets/demo.svg" width="100%">
61
+ <img alt="Terminal recording: trackfetch skips two songs that are already downloaded, finds Daft Punk - One More Time on Spotify, scores five YouTube results, downloads the best one and saves a tagged MP3" src="https://raw.githubusercontent.com/ByteMe6/trackfetch/master/.github/assets/demo.svg" width="100%">
61
62
 
62
63
  <br>
63
64
 
@@ -66,7 +67,7 @@ trackfetch reads a plain text file with one `Artist - Title` per line. For every
66
67
  - **Exact metadata.** The title, every credited artist, album, album artist, track number and release date come from Spotify, not from a YouTube video title.
67
68
  - **Album art.** The largest Spotify cover is embedded in every file.
68
69
  - **The right upload.** trackfetch scores five YouTube results per song. Official audio wins; covers, karaoke, nightcore, sped-up, slowed, remix and reaction videos lose.
69
- - **Best quality.** yt-dlp extracts the audio as the highest-quality VBR MP3.
70
+ - **Your choice of format.** MP3 for any player, or Opus and M4A that keep YouTube's original audio without re-encoding.
70
71
  - **Resumable.** Finished songs are logged in `done.txt` and skipped next time. Failures go to `failed.txt` with the reason.
71
72
  - **Plays everywhere.** Tags are written as ID3v2.3, which Windows Explorer, Apple Music, Android, car stereos and most other players read.
72
73
  - **Any alphabet.** Cyrillic and other non-Latin titles are matched correctly and kept in filenames. Characters that are illegal in filenames are replaced.
@@ -74,13 +75,32 @@ trackfetch reads a plain text file with one `Artist - Title` per line. For every
74
75
 
75
76
  ## Install
76
77
 
77
- trackfetch needs these tools on your `PATH`, plus a free Spotify API key ([set it up below](#spotify-credentials)):
78
+ Pick one. Homebrew and Nix also install yt-dlp, FFmpeg and Deno for you; with the other methods, install them yourself ([see below](#dependencies)). Every method needs a free Spotify API key ([set it up below](#spotify-credentials)).
78
79
 
79
- | Tool | Used for | Install |
80
- | --- | --- | --- |
81
- | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | Searching and downloading from YouTube | `pipx install yt-dlp` · `brew install yt-dlp` · `winget install yt-dlp` |
82
- | [FFmpeg](https://ffmpeg.org/) | Converting the audio to MP3 | `sudo apt install ffmpeg` · `brew install ffmpeg` · `winget install ffmpeg` |
83
- | [Deno](https://deno.com/) | JavaScript runtime that yt-dlp needs for YouTube | `curl -fsSL https://deno.land/install.sh \| sh` · `brew install deno` · `winget install DenoLand.Deno` |
80
+ ### Homebrew
81
+
82
+ macOS and Linux:
83
+
84
+ ```bash
85
+ brew install ByteMe6/tap/trackfetch
86
+ ```
87
+
88
+ On Intel Macs, Homebrew builds the dependencies from source, so the first install takes a while.
89
+
90
+ ### Nix
91
+
92
+ Linux and Apple Silicon Macs, with flakes enabled:
93
+
94
+ ```bash
95
+ nix run github:ByteMe6/trackfetch -- songs.txt # run without installing
96
+ nix profile install github:ByteMe6/trackfetch # install
97
+ ```
98
+
99
+ ### pipx
100
+
101
+ ```bash
102
+ pipx install trackfetch
103
+ ```
84
104
 
85
105
  ### Standalone binary
86
106
 
@@ -101,12 +121,6 @@ sudo mv trackfetch-linux-x86_64 /usr/local/bin/trackfetch
101
121
 
102
122
  On macOS, if Gatekeeper blocks the unsigned binary, run `xattr -d com.apple.quarantine trackfetch-macos-*` once. Checksums are in `SHA256SUMS.txt` on each release.
103
123
 
104
- ### pipx
105
-
106
- ```bash
107
- pipx install git+https://github.com/ByteMe6/trackfetch.git
108
- ```
109
-
110
124
  ### From source
111
125
 
112
126
  ```bash
@@ -116,6 +130,16 @@ python -m venv .venv && source .venv/bin/activate
116
130
  pip install -e .
117
131
  ```
118
132
 
133
+ ### Dependencies
134
+
135
+ With pipx, a standalone binary or a source install, put these on your `PATH`:
136
+
137
+ | Tool | Used for | Install |
138
+ | --- | --- | --- |
139
+ | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | Searching and downloading from YouTube | `pipx install yt-dlp` · `brew install yt-dlp` · `winget install yt-dlp` |
140
+ | [FFmpeg](https://ffmpeg.org/) | Converting the audio to MP3 | `sudo apt install ffmpeg` · `brew install ffmpeg` · `winget install ffmpeg` |
141
+ | [Deno](https://deno.com/) | JavaScript runtime that yt-dlp needs for YouTube | `curl -fsSL https://deno.land/install.sh \| sh` · `brew install deno` · `winget install DenoLand.Deno` |
142
+
119
143
  ## Spotify credentials
120
144
 
121
145
  trackfetch uses Spotify's Client Credentials flow: no Spotify login, no access to your account.
@@ -189,13 +213,14 @@ trackfetch "My Playlist.txt" -o ~/Music/"My Playlist"
189
213
  ## Usage
190
214
 
191
215
  ```text
192
- trackfetch [-h] [-o OUTPUT] [--title-only] [--delay DELAY] input
216
+ trackfetch [-h] [-o OUTPUT] [-f {mp3,m4a,opus}] [--title-only] [--delay DELAY] input
193
217
  ```
194
218
 
195
219
  | Option | Default | What it does |
196
220
  | --- | --- | --- |
197
221
  | `input` | required | Text file with one `Artist - Title` per line |
198
- | `-o`, `--output` | `~/Music/trackfetch` | Folder to save the MP3s in. It's created if it doesn't exist. |
222
+ | `-o`, `--output` | `~/Music/trackfetch` | Folder to save the files in. It's created if it doesn't exist. |
223
+ | `-f`, `--format` | `mp3` | Audio format: `mp3`, `m4a` or `opus`. See [Audio formats](#audio-formats). |
199
224
  | `--title-only` | off | Name files `Title.mp3` instead of `Artist - Title.mp3` |
200
225
  | `--delay` | `1.0` | Seconds to wait between songs. Raise it for long lists. |
201
226
 
@@ -203,8 +228,19 @@ trackfetch [-h] [-o OUTPUT] [--title-only] [--delay DELAY] input
203
228
  trackfetch songs.txt -o ~/Music/RoadTrip # save to a specific folder
204
229
  trackfetch songs.txt -o /media/usb --title-only # short names for a car stereo
205
230
  trackfetch big-list.txt --delay 3 # go easy on rate limits
231
+ trackfetch songs.txt --format opus # original YouTube audio, no re-encode
206
232
  ```
207
233
 
234
+ ### Audio formats
235
+
236
+ | Format | What you get | Plays on |
237
+ | --- | --- | --- |
238
+ | `mp3` (default) | YouTube's audio **re-encoded** to the best VBR MP3. A second lossy encode, so slightly below the source | everything, including old players and car stereos |
239
+ | `opus` | YouTube's **original** Opus audio, copied without re-encoding. The best quality you can get | most modern players, Android, browsers; not iTunes or older devices |
240
+ | `m4a` | YouTube's **original** AAC audio when available, copied without re-encoding | Apple devices, iTunes, most players |
241
+
242
+ 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
+
208
244
  ### Input file
209
245
 
210
246
  ```text
@@ -213,7 +249,7 @@ Daft Punk - One More Time
213
249
  Sufjan Stevens - Mystery of Love - Remastered ← only the first " - " separates artist and title
214
250
  ```
215
251
 
216
- The separator is space, hyphen, space (` - `). Lines without it are skipped. See [`examples/songs.txt`](examples/songs.txt).
252
+ The separator is space, hyphen, space (` - `). Lines without it are skipped. See [`examples/songs.txt`](https://github.com/ByteMe6/trackfetch/blob/master/examples/songs.txt).
217
253
 
218
254
  ### Output folder
219
255
 
@@ -236,7 +272,7 @@ trackfetch retry.txt
236
272
 
237
273
  ### Cheat sheet
238
274
 
239
- A [tldr](https://tldr.sh/) page is in [`docs/tldr/trackfetch.md`](docs/tldr/trackfetch.md). To use it with [tealdeer](https://github.com/tealdeer-rs/tealdeer), copy it into your custom pages folder as `trackfetch.page.md`.
275
+ A [tldr](https://tldr.sh/) page is in [`docs/tldr/trackfetch.md`](https://github.com/ByteMe6/trackfetch/blob/master/docs/tldr/trackfetch.md). To use it with [tealdeer](https://github.com/tealdeer-rs/tealdeer), copy it into your custom pages folder as `trackfetch.page.md`.
240
276
 
241
277
  ## How it works
242
278
 
@@ -313,11 +349,11 @@ pytest --cov=trackfetch
313
349
 
314
350
  The tests stub every network and subprocess call, so they run offline in under a second. To build a standalone binary yourself, run `pip install pyinstaller && pyinstaller trackfetch.spec`; it ends up in `dist/`.
315
351
 
316
- See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
352
+ See [CONTRIBUTING.md](https://github.com/ByteMe6/trackfetch/blob/master/CONTRIBUTING.md) before opening a pull request.
317
353
 
318
354
  ### Releasing
319
355
 
320
- Every push to `master` runs the tests on Linux, macOS and Windows. When `version` in [`pyproject.toml`](pyproject.toml) has no matching `vX.Y.Z` tag yet, the [Build & Release](.github/workflows/release.yml) workflow also builds all six binaries and publishes a release, using that version's section of [`CHANGELOG.md`](CHANGELOG.md) as the notes.
356
+ Every push to `master` runs the tests on Linux, macOS and Windows. When `version` in [`pyproject.toml`](https://github.com/ByteMe6/trackfetch/blob/master/pyproject.toml) has no matching `vX.Y.Z` tag yet, the [Build & Release](https://github.com/ByteMe6/trackfetch/blob/master/.github/workflows/release.yml) workflow also builds all six binaries and publishes a release, using that version's section of [`CHANGELOG.md`](https://github.com/ByteMe6/trackfetch/blob/master/CHANGELOG.md) as the notes.
321
357
 
322
358
  To release, move the changes under `[Unreleased]` in the changelog to a new `## [X.Y.Z] - YYYY-MM-DD` section, bump `version`, and push.
323
359
 
@@ -330,4 +366,4 @@ trackfetch is for personal use with content you have the right to download. You
330
366
 
331
367
  ## License
332
368
 
333
- trackfetch is free software, released under the [GNU General Public License v3.0 or later](LICENSE). You can use, study, change and share it. If you distribute a modified version, it must stay under the same license.
369
+ trackfetch is free software, released under the [GNU General Public License v3.0 or later](https://github.com/ByteMe6/trackfetch/blob/master/LICENSE). You can use, study, change and share it. If you distribute a modified version, it must stay under the same license.
@@ -2,6 +2,7 @@
2
2
  # SPDX-License-Identifier: GPL-3.0-or-later
3
3
 
4
4
  import argparse
5
+ import base64
5
6
  import json
6
7
  import os
7
8
  import re
@@ -16,6 +17,7 @@ from pathlib import Path
16
17
 
17
18
  import requests
18
19
  import spotipy
20
+ from mutagen.flac import Picture
19
21
  from mutagen.id3 import (
20
22
  APIC,
21
23
  ID3,
@@ -26,10 +28,24 @@ from mutagen.id3 import (
26
28
  TPE2,
27
29
  TRCK,
28
30
  )
31
+ from mutagen.mp4 import MP4, MP4Cover
32
+ from mutagen.oggopus import OggOpus
29
33
  from spotipy.oauth2 import SpotifyClientCredentials
30
34
 
31
35
  YTDLP_REMOTE_COMPONENTS = "ejs:github"
32
36
 
37
+ AUDIO_FORMATS = {
38
+ "mp3": [],
39
+ "m4a": [
40
+ "-f",
41
+ "bestaudio[ext=m4a]/bestaudio",
42
+ ],
43
+ "opus": [
44
+ "-f",
45
+ "bestaudio[acodec=opus]/bestaudio",
46
+ ],
47
+ }
48
+
33
49
 
34
50
  def normalize(text: str) -> str:
35
51
  text = unicodedata.normalize("NFKD", text).lower()
@@ -184,7 +200,7 @@ def download_cover(url: str, destination: Path):
184
200
  url,
185
201
  timeout=30,
186
202
  headers={
187
- "User-Agent": "trackfetch/2.0"
203
+ "User-Agent": "trackfetch/2.1"
188
204
  },
189
205
  )
190
206
 
@@ -306,14 +322,16 @@ def find_youtube_video(artist: str, title: str):
306
322
  def download_audio(
307
323
  video_url: str,
308
324
  temp_dir: Path,
325
+ audio_format: str = "mp3",
309
326
  ):
310
327
  template = str(temp_dir / "audio.%(ext)s")
311
328
 
312
329
  command = [
313
330
  "yt-dlp",
331
+ *AUDIO_FORMATS[audio_format],
314
332
  "-x",
315
333
  "--audio-format",
316
- "mp3",
334
+ audio_format,
317
335
  "--audio-quality",
318
336
  "0",
319
337
  "--remote-components",
@@ -329,7 +347,7 @@ def download_audio(
329
347
  if result.returncode != 0:
330
348
  return None
331
349
 
332
- files = list(temp_dir.glob("audio.mp3"))
350
+ files = list(temp_dir.glob(f"audio.{audio_format}"))
333
351
 
334
352
  if not files:
335
353
  return None
@@ -338,6 +356,31 @@ def download_audio(
338
356
 
339
357
 
340
358
  def add_metadata(
359
+ audio_path: Path,
360
+ cover_path: Path,
361
+ metadata: dict,
362
+ ):
363
+ if audio_path.suffix == ".opus":
364
+ add_opus_metadata(
365
+ audio_path,
366
+ cover_path,
367
+ metadata,
368
+ )
369
+ elif audio_path.suffix == ".m4a":
370
+ add_m4a_metadata(
371
+ audio_path,
372
+ cover_path,
373
+ metadata,
374
+ )
375
+ else:
376
+ add_mp3_metadata(
377
+ audio_path,
378
+ cover_path,
379
+ metadata,
380
+ )
381
+
382
+
383
+ def add_mp3_metadata(
341
384
  mp3_path: Path,
342
385
  cover_path: Path,
343
386
  metadata: dict,
@@ -423,7 +466,84 @@ def add_metadata(
423
466
  )
424
467
 
425
468
 
426
- def make_filename(metadata, title_only):
469
+ def add_opus_metadata(
470
+ opus_path: Path,
471
+ cover_path: Path,
472
+ metadata: dict,
473
+ ):
474
+ audio = OggOpus(opus_path)
475
+
476
+ fields = {
477
+ "TITLE": metadata["title"],
478
+ "ARTIST": metadata["artist"],
479
+ "ALBUM": metadata["album"],
480
+ "ALBUMARTIST": metadata["album_artist"],
481
+ "TRACKNUMBER": metadata["track_number"],
482
+ "DATE": metadata["release_date"],
483
+ }
484
+
485
+ for key in [*fields, "METADATA_BLOCK_PICTURE"]:
486
+ if key in audio:
487
+ del audio[key]
488
+
489
+ for key, value in fields.items():
490
+ if value:
491
+ audio[key] = str(value)
492
+
493
+ if cover_path.exists():
494
+ picture = Picture()
495
+ picture.type = 3
496
+ picture.mime = "image/jpeg"
497
+ picture.desc = "Cover"
498
+ picture.data = cover_path.read_bytes()
499
+
500
+ audio["METADATA_BLOCK_PICTURE"] = base64.b64encode(
501
+ picture.write()
502
+ ).decode("ascii")
503
+
504
+ audio.save()
505
+
506
+
507
+ def add_m4a_metadata(
508
+ m4a_path: Path,
509
+ cover_path: Path,
510
+ metadata: dict,
511
+ ):
512
+ audio = MP4(m4a_path)
513
+
514
+ fields = {
515
+ "\xa9nam": metadata["title"],
516
+ "\xa9ART": metadata["artist"],
517
+ "\xa9alb": metadata["album"],
518
+ "aART": metadata["album_artist"],
519
+ "\xa9day": metadata["release_date"],
520
+ }
521
+
522
+ for key in [*fields, "trkn", "covr"]:
523
+ if key in audio:
524
+ del audio[key]
525
+
526
+ for key, value in fields.items():
527
+ if value:
528
+ audio[key] = [str(value)]
529
+
530
+ if metadata["track_number"]:
531
+ audio["trkn"] = [
532
+ (int(metadata["track_number"]), 0)
533
+ ]
534
+
535
+ if cover_path.exists():
536
+ audio["covr"] = [
537
+ MP4Cover(
538
+ cover_path.read_bytes(),
539
+ imageformat=MP4Cover.FORMAT_JPEG,
540
+ )
541
+ ]
542
+
543
+ audio.save()
544
+
545
+
546
+ def make_filename(metadata, title_only, audio_format="mp3"):
427
547
  if title_only:
428
548
  name = metadata["title"]
429
549
  else:
@@ -432,7 +552,7 @@ def make_filename(metadata, title_only):
432
552
  f"{metadata['title']}"
433
553
  )
434
554
 
435
- return sanitize_filename(name) + ".mp3"
555
+ return sanitize_filename(name) + "." + audio_format
436
556
 
437
557
 
438
558
  def read_done(done_file: Path):
@@ -463,6 +583,7 @@ def process_song(
463
583
  input_title,
464
584
  output,
465
585
  title_only,
586
+ audio_format="mp3",
466
587
  ):
467
588
  track = find_spotify_track(
468
589
  sp,
@@ -485,6 +606,7 @@ def process_song(
485
606
  filename = make_filename(
486
607
  metadata,
487
608
  title_only,
609
+ audio_format,
488
610
  )
489
611
 
490
612
  final_path = output / filename
@@ -523,12 +645,13 @@ def process_song(
523
645
  with tempfile.TemporaryDirectory() as temp:
524
646
  temp_dir = Path(temp)
525
647
 
526
- mp3_path = download_audio(
648
+ audio_path = download_audio(
527
649
  video_url,
528
650
  temp_dir,
651
+ audio_format,
529
652
  )
530
653
 
531
- if not mp3_path:
654
+ if not audio_path:
532
655
  return False, "yt-dlp download failed"
533
656
 
534
657
  cover_path = temp_dir / "cover.jpg"
@@ -550,7 +673,7 @@ def process_song(
550
673
 
551
674
  try:
552
675
  add_metadata(
553
- mp3_path,
676
+ audio_path,
554
677
  cover_path,
555
678
  metadata,
556
679
  )
@@ -560,7 +683,7 @@ def process_song(
560
683
 
561
684
  try:
562
685
  shutil.move(
563
- str(mp3_path),
686
+ str(audio_path),
564
687
  str(final_path),
565
688
  )
566
689
 
@@ -594,6 +717,14 @@ def main():
594
717
  help="Output directory",
595
718
  )
596
719
 
720
+ parser.add_argument(
721
+ "-f",
722
+ "--format",
723
+ choices=list(AUDIO_FORMATS),
724
+ default="mp3",
725
+ help="Audio format",
726
+ )
727
+
597
728
  parser.add_argument(
598
729
  "--title-only",
599
730
  action="store_true",
@@ -672,6 +803,7 @@ def main():
672
803
  title,
673
804
  args.output,
674
805
  args.title_only,
806
+ args.format,
675
807
  )
676
808
 
677
809
  if ok:
File without changes
File without changes