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.
- {trackfetch-2.0.0/trackfetch.egg-info → trackfetch-2.1.0}/PKG-INFO +62 -26
- {trackfetch-2.0.0 → trackfetch-2.1.0}/README.md +61 -25
- {trackfetch-2.0.0 → trackfetch-2.1.0}/pyproject.toml +1 -1
- {trackfetch-2.0.0 → trackfetch-2.1.0}/tests/test_pipeline.py +53 -4
- {trackfetch-2.0.0 → trackfetch-2.1.0}/tests/test_tags_and_files.py +105 -0
- {trackfetch-2.0.0 → trackfetch-2.1.0}/tests/test_text.py +5 -0
- {trackfetch-2.0.0 → trackfetch-2.1.0}/tests/test_youtube.py +30 -0
- {trackfetch-2.0.0 → trackfetch-2.1.0/trackfetch.egg-info}/PKG-INFO +62 -26
- {trackfetch-2.0.0 → trackfetch-2.1.0}/trackfetch.py +141 -9
- {trackfetch-2.0.0 → trackfetch-2.1.0}/LICENSE +0 -0
- {trackfetch-2.0.0 → trackfetch-2.1.0}/setup.cfg +0 -0
- {trackfetch-2.0.0 → trackfetch-2.1.0}/tests/test_spotify.py +0 -0
- {trackfetch-2.0.0 → trackfetch-2.1.0}/trackfetch.egg-info/SOURCES.txt +0 -0
- {trackfetch-2.0.0 → trackfetch-2.1.0}/trackfetch.egg-info/dependency_links.txt +0 -0
- {trackfetch-2.0.0 → trackfetch-2.1.0}/trackfetch.egg-info/entry_points.txt +0 -0
- {trackfetch-2.0.0 → trackfetch-2.1.0}/trackfetch.egg-info/requires.txt +0 -0
- {trackfetch-2.0.0 → trackfetch-2.1.0}/trackfetch.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trackfetch
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.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
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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.
|
|
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.
|
|
31
|
-
|
|
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.
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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) + ".
|
|
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
|
-
|
|
648
|
+
audio_path = download_audio(
|
|
527
649
|
video_url,
|
|
528
650
|
temp_dir,
|
|
651
|
+
audio_format,
|
|
529
652
|
)
|
|
530
653
|
|
|
531
|
-
if not
|
|
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
|
-
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|