audiolibrarian 0.16.2__py3-none-any.whl
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.
- audiolibrarian/__init__.py +19 -0
- audiolibrarian/audiofile/__init__.py +23 -0
- audiolibrarian/audiofile/audiofile.py +114 -0
- audiolibrarian/audiofile/formats/__init__.py +1 -0
- audiolibrarian/audiofile/formats/flac.py +207 -0
- audiolibrarian/audiofile/formats/m4a.py +221 -0
- audiolibrarian/audiofile/formats/mp3.py +259 -0
- audiolibrarian/audiofile/tags.py +48 -0
- audiolibrarian/audiosource.py +215 -0
- audiolibrarian/base.py +433 -0
- audiolibrarian/cli.py +123 -0
- audiolibrarian/commands.py +283 -0
- audiolibrarian/genremanager.py +176 -0
- audiolibrarian/musicbrainz.py +465 -0
- audiolibrarian/output.py +57 -0
- audiolibrarian/records.py +259 -0
- audiolibrarian/settings.py +79 -0
- audiolibrarian/sh.py +55 -0
- audiolibrarian/text.py +115 -0
- audiolibrarian-0.16.2.dist-info/METADATA +334 -0
- audiolibrarian-0.16.2.dist-info/RECORD +28 -0
- audiolibrarian-0.16.2.dist-info/WHEEL +4 -0
- audiolibrarian-0.16.2.dist-info/entry_points.txt +2 -0
- audiolibrarian-0.16.2.dist-info/licenses/COPYING +674 -0
- audiolibrarian-0.16.2.dist-info/licenses/LICENSE +674 -0
- picard_src/README.md +11 -0
- picard_src/__init__.py +7 -0
- picard_src/textencoding.py +495 -0
audiolibrarian/base.py
ADDED
@@ -0,0 +1,433 @@
|
|
1
|
+
"""AudioLibrarian base class.
|
2
|
+
|
3
|
+
Useful stuff: https://help.mp3tag.de/main_tags.html
|
4
|
+
"""
|
5
|
+
|
6
|
+
#
|
7
|
+
# Copyright (c) 2020 Stephen Jibson
|
8
|
+
#
|
9
|
+
# This file is part of audiolibrarian.
|
10
|
+
#
|
11
|
+
# Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
|
12
|
+
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
|
13
|
+
# License, or (at your option) any later version.
|
14
|
+
#
|
15
|
+
# Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
16
|
+
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
17
|
+
# the GNU General Public License for more details.
|
18
|
+
#
|
19
|
+
# You should have received a copy of the GNU General Public License along with audiolibrarian.
|
20
|
+
# If not, see <https://www.gnu.org/licenses/>.
|
21
|
+
#
|
22
|
+
import argparse
|
23
|
+
import logging
|
24
|
+
import pathlib
|
25
|
+
import shutil
|
26
|
+
import subprocess
|
27
|
+
import sys
|
28
|
+
import warnings
|
29
|
+
from collections.abc import Iterable
|
30
|
+
from typing import Any
|
31
|
+
|
32
|
+
import colors
|
33
|
+
import filelock
|
34
|
+
import yaml
|
35
|
+
|
36
|
+
from audiolibrarian import audiofile, audiosource, musicbrainz, records, sh, text
|
37
|
+
from audiolibrarian.settings import SETTINGS
|
38
|
+
|
39
|
+
log = logging.getLogger(__name__)
|
40
|
+
|
41
|
+
|
42
|
+
class Base:
|
43
|
+
"""AudioLibrarian base class.
|
44
|
+
|
45
|
+
This class should be sub-classed for various sub_commands.
|
46
|
+
"""
|
47
|
+
|
48
|
+
command: str | None = None
|
49
|
+
|
50
|
+
def __init__(self, args: argparse.Namespace) -> None:
|
51
|
+
"""Initialize the base."""
|
52
|
+
# Pull in stuff from args.
|
53
|
+
search_keys = ("album", "artist", "mb_artist_id", "mb_release_id")
|
54
|
+
self._provided_search_data = {k: v for k, v in vars(args).items() if k in search_keys}
|
55
|
+
if vars(args).get("disc"):
|
56
|
+
self._disc_number, self._disc_count = [int(x) for x in args.disc.split("/")]
|
57
|
+
else:
|
58
|
+
self._disc_number, self._disc_count = 1, 1
|
59
|
+
|
60
|
+
# Directories.
|
61
|
+
self._library_dir = SETTINGS.library_dir
|
62
|
+
self._work_dir = SETTINGS.work_dir
|
63
|
+
self._flac_dir = self._work_dir / "flac"
|
64
|
+
self._m4a_dir = self._work_dir / "m4a"
|
65
|
+
self._mp3_dir = self._work_dir / "mp3"
|
66
|
+
self._source_dir = self._work_dir / "source"
|
67
|
+
self._wav_dir = self._work_dir / "wav"
|
68
|
+
|
69
|
+
self._manifest_file = "Manifest.yaml"
|
70
|
+
self._lock = filelock.FileLock(str(self._work_dir) + ".lock")
|
71
|
+
|
72
|
+
# Initialize stuff that will be defined later.
|
73
|
+
self._audio_source: audiosource.AudioSource | None = None
|
74
|
+
self._release: records.Release | None = None
|
75
|
+
self._medium: records.Medium | None = None
|
76
|
+
self._source_is_cd: bool | None = None
|
77
|
+
self._source_example: records.OneTrack | None = None
|
78
|
+
|
79
|
+
@property
|
80
|
+
def _flac_filenames(self) -> list[pathlib.Path]:
|
81
|
+
"""Return the current list of flac files in the work directory."""
|
82
|
+
return sorted(self._flac_dir.glob("*.flac"), key=text.alpha_numeric_key)
|
83
|
+
|
84
|
+
@property
|
85
|
+
def _m4a_filenames(self) -> list[pathlib.Path]:
|
86
|
+
"""Return the current list of m4a files in the work directory."""
|
87
|
+
return sorted(self._m4a_dir.glob("*.m4a"), key=text.alpha_numeric_key)
|
88
|
+
|
89
|
+
@property
|
90
|
+
def _mp3_filenames(self) -> list[pathlib.Path]:
|
91
|
+
"""Return the current list of mp3 files in the work directory."""
|
92
|
+
return sorted(self._mp3_dir.glob("*.mp3"), key=text.alpha_numeric_key)
|
93
|
+
|
94
|
+
@property
|
95
|
+
def _multi_disc(self) -> bool:
|
96
|
+
"""Return True if this is part of a multi-disc set."""
|
97
|
+
return (self._disc_number, self._disc_count) != (1, 1)
|
98
|
+
|
99
|
+
@property
|
100
|
+
def _source_filenames(self) -> list[pathlib.Path]:
|
101
|
+
"""Return the current list of source files in the work directory."""
|
102
|
+
return sorted(self._source_dir.glob("*.flac"), key=text.alpha_numeric_key)
|
103
|
+
|
104
|
+
@property
|
105
|
+
def _wav_filenames(self) -> list[pathlib.Path]:
|
106
|
+
"""Return the current list of wav files in the work directory."""
|
107
|
+
return sorted(self._wav_dir.glob("*.wav"), key=text.alpha_numeric_key)
|
108
|
+
|
109
|
+
def _convert(self, *, make_source: bool = True) -> None:
|
110
|
+
"""Perform all the steps of ripping, normalizing, converting and moving the files."""
|
111
|
+
if self._audio_source is None:
|
112
|
+
warnings.warn(
|
113
|
+
"Cannot convert; no audio_source is defined.", RuntimeWarning, stacklevel=2
|
114
|
+
)
|
115
|
+
return
|
116
|
+
self._audio_source.prepare_source()
|
117
|
+
with self._lock:
|
118
|
+
self._make_clean_workdirs()
|
119
|
+
self._audio_source.copy_wavs(self._wav_dir)
|
120
|
+
self._rename_wav()
|
121
|
+
if make_source:
|
122
|
+
self._make_source()
|
123
|
+
self._normalize()
|
124
|
+
self._make_flac()
|
125
|
+
self._make_m4a()
|
126
|
+
self._make_mp3()
|
127
|
+
self._move_files(move_source=make_source)
|
128
|
+
|
129
|
+
@staticmethod
|
130
|
+
def _find_audio_files(directories: list[str | pathlib.Path]) -> Iterable[audiofile.AudioFile]:
|
131
|
+
"""Yield audiofile objects found in the given directories."""
|
132
|
+
paths: list[pathlib.Path] = []
|
133
|
+
# Grab all the paths first because thing may change as files are renamed.
|
134
|
+
for directory in directories:
|
135
|
+
path = pathlib.Path(directory)
|
136
|
+
for ext in audiofile.AudioFile.extensions():
|
137
|
+
paths.extend(path.rglob(f"*{ext}"))
|
138
|
+
paths = sorted(set(paths))
|
139
|
+
# Using yield rather than returning a list saves us from simultaneously storing
|
140
|
+
# potentially thousands of AudioFile objects in memory at the same time.
|
141
|
+
for path in paths:
|
142
|
+
try:
|
143
|
+
yield audiofile.AudioFile.open(path)
|
144
|
+
except FileNotFoundError:
|
145
|
+
continue
|
146
|
+
|
147
|
+
def _find_manifests(self, directories: list[str | pathlib.Path]) -> list[pathlib.Path]:
|
148
|
+
"""Return a sorted, unique list of manifest files anywhere in the given directories."""
|
149
|
+
manifests = set()
|
150
|
+
for directory in directories:
|
151
|
+
path = pathlib.Path(directory)
|
152
|
+
for manifest in path.rglob(self._manifest_file):
|
153
|
+
manifests.add(manifest)
|
154
|
+
return sorted(manifests)
|
155
|
+
|
156
|
+
def _get_searcher(self) -> musicbrainz.Searcher:
|
157
|
+
"""Return a Searcher object populated with data from the audio source and cli args."""
|
158
|
+
search_data: dict[str, str] = (
|
159
|
+
self._audio_source.get_search_data() if self._audio_source is not None else {}
|
160
|
+
)
|
161
|
+
searcher = musicbrainz.Searcher(**search_data) # type: ignore[arg-type]
|
162
|
+
searcher.disc_number = str(self._disc_number)
|
163
|
+
# Override with user-provided info.
|
164
|
+
if value := self._provided_search_data.get("artist"):
|
165
|
+
searcher.artist = value
|
166
|
+
if value := self._provided_search_data.get("album"):
|
167
|
+
searcher.album = value
|
168
|
+
if value := self._provided_search_data.get("mb_artist_id"):
|
169
|
+
searcher.mb_artist_id = value
|
170
|
+
if value := self._provided_search_data.get("mb_release_id"):
|
171
|
+
searcher.mb_release_id = value
|
172
|
+
log.info("SEARCHER: %s", searcher)
|
173
|
+
return searcher
|
174
|
+
|
175
|
+
def _get_tag_info(self) -> None:
|
176
|
+
"""Gather search information from user-provided options and source files.
|
177
|
+
|
178
|
+
Set `self._release` and `self._medium` based on the search results.
|
179
|
+
If the search results don't have a front cover, use the front cover from the source files.
|
180
|
+
Print a summary of the results and prompt the user to confirm.
|
181
|
+
If the track count does not match the file count, print an error message.
|
182
|
+
If the user does not confirm, exit the program.
|
183
|
+
"""
|
184
|
+
print("Gathering search information...")
|
185
|
+
searcher = self._get_searcher()
|
186
|
+
skip_confirm = bool(searcher.mb_artist_id and searcher.mb_release_id)
|
187
|
+
print("Finding MusicBrainz release information...")
|
188
|
+
self._release = searcher.find_music_brains_release()
|
189
|
+
self._medium = self._release.media[int(self._disc_number)]
|
190
|
+
if (
|
191
|
+
not self._release.front_cover
|
192
|
+
and self._audio_source
|
193
|
+
and (cover := self._audio_source.get_front_cover())
|
194
|
+
):
|
195
|
+
log.info("Using front-cover image from source file")
|
196
|
+
self._release.front_cover = cover
|
197
|
+
summary, okay = self._summary()
|
198
|
+
print(summary)
|
199
|
+
if not okay:
|
200
|
+
print(colors.color("\n*** Track count does not match file count ***\n", fg="red"))
|
201
|
+
skip_confirm = False
|
202
|
+
if not skip_confirm and text.input_("Confirm [N,y]: ").lower() != "y": # pragma: no cover
|
203
|
+
sys.exit(1)
|
204
|
+
|
205
|
+
def _make_clean_workdirs(self) -> None:
|
206
|
+
"""Erase everything from the workdir and create the empty directory structure."""
|
207
|
+
if self._work_dir.is_dir():
|
208
|
+
shutil.rmtree(self._work_dir)
|
209
|
+
for path in self._flac_dir, self._m4a_dir, self._mp3_dir, self._source_dir, self._wav_dir:
|
210
|
+
path.mkdir(parents=True)
|
211
|
+
|
212
|
+
def _make_flac(self, *, source: bool = False) -> None:
|
213
|
+
"""Convert the wav files into flac files; tag them.
|
214
|
+
|
215
|
+
If source is True, it stores the flac files in the source directory,
|
216
|
+
otherwise, it stores them in the flac directory.
|
217
|
+
"""
|
218
|
+
out_dir = self._source_dir if source else self._flac_dir
|
219
|
+
commands: list[tuple[str, ...]] = [
|
220
|
+
("flac", "--silent", f"--output-prefix={out_dir}/", str(f))
|
221
|
+
for f in self._wav_filenames
|
222
|
+
]
|
223
|
+
sh.parallel(f"Making {len(self._wav_filenames)} flac files...", commands)
|
224
|
+
filenames = self._source_filenames if source else self._flac_filenames
|
225
|
+
sh.touch(filenames)
|
226
|
+
self._tag_files(filenames)
|
227
|
+
|
228
|
+
def _make_m4a(self) -> None:
|
229
|
+
"""Convert the wav files into m4a files; tag them."""
|
230
|
+
commands: list[tuple[str, ...]] = []
|
231
|
+
for filename in self._wav_filenames:
|
232
|
+
dst_file = self._m4a_dir / filename.name.replace(".wav", ".m4a")
|
233
|
+
commands.append(
|
234
|
+
("fdkaac", "--silent", "--bitrate-mode=5", "-o", str(dst_file), str(filename))
|
235
|
+
)
|
236
|
+
sh.parallel(f"Making {len(commands)} m4a files...", commands)
|
237
|
+
sh.touch(self._m4a_filenames)
|
238
|
+
self._tag_files(self._m4a_filenames)
|
239
|
+
|
240
|
+
def _make_mp3(self) -> None:
|
241
|
+
"""Convert the wav files into mp3 files; tag them."""
|
242
|
+
commands: list[tuple[str, ...]] = []
|
243
|
+
for filename in self._wav_filenames:
|
244
|
+
dst_file = self._mp3_dir / filename.name.replace(".wav", ".mp3")
|
245
|
+
commands.append(("lame", "--silent", "-h", "-b", "192", str(filename), str(dst_file)))
|
246
|
+
sh.parallel(f"Making {len(commands)} mp3 files...", commands)
|
247
|
+
sh.touch(self._mp3_filenames)
|
248
|
+
self._tag_files(self._mp3_filenames)
|
249
|
+
|
250
|
+
def _make_source(self) -> None:
|
251
|
+
"""Convert the files into flac files; store them in the source dir; read their tags.
|
252
|
+
|
253
|
+
The files are defined by the audio source; they could be wav files from a CD
|
254
|
+
or another type of audio file.
|
255
|
+
"""
|
256
|
+
self._make_flac(source=True)
|
257
|
+
self._source_example = audiofile.AudioFile.open(self._source_filenames[0]).read_tags()
|
258
|
+
|
259
|
+
def _move_files(self, *, move_source: bool = True) -> None:
|
260
|
+
"""Move converted/tagged files from the work directory into the library directory."""
|
261
|
+
artist_album_dir = self._release.get_artist_album_path()
|
262
|
+
flac_dir = self._library_dir / "flac" / artist_album_dir
|
263
|
+
m4a_dir = self._library_dir / "m4a" / artist_album_dir
|
264
|
+
mp3_dir = self._library_dir / "mp3" / artist_album_dir
|
265
|
+
source_dir = self._library_dir / "source" / artist_album_dir
|
266
|
+
if self._multi_disc:
|
267
|
+
flac_dir /= f"disc{self._disc_number}"
|
268
|
+
m4a_dir /= f"disc{self._disc_number}"
|
269
|
+
mp3_dir /= f"disc{self._disc_number}"
|
270
|
+
source_dir /= f"disc{self._disc_number}"
|
271
|
+
for path in [flac_dir, m4a_dir, mp3_dir] + ([source_dir] if move_source else []):
|
272
|
+
if path.is_dir():
|
273
|
+
shutil.rmtree(path)
|
274
|
+
path.mkdir(parents=True)
|
275
|
+
for path in self._flac_filenames:
|
276
|
+
path.rename(flac_dir / path.name)
|
277
|
+
for path in self._m4a_filenames:
|
278
|
+
path.rename(m4a_dir / path.name)
|
279
|
+
for path in self._mp3_filenames:
|
280
|
+
path.rename(mp3_dir / path.name)
|
281
|
+
if move_source:
|
282
|
+
for path in self._source_filenames:
|
283
|
+
path.rename(source_dir / path.name)
|
284
|
+
|
285
|
+
def _normalize(self) -> None:
|
286
|
+
"""Normalize the wav files using wavegain."""
|
287
|
+
print("Normalizing wav files...")
|
288
|
+
command = [
|
289
|
+
"wavegain",
|
290
|
+
f"--{SETTINGS.normalize_preset}",
|
291
|
+
f"--gain={SETTINGS.normalize_gain}",
|
292
|
+
"--apply",
|
293
|
+
]
|
294
|
+
command.extend(str(f) for f in self._wav_filenames)
|
295
|
+
result = subprocess.run(command, capture_output=True, check=False) # noqa: S603
|
296
|
+
for line in str(result.stderr).split(r"\n"):
|
297
|
+
line_trunc = line[:137] + "..." if len(line) > 140 else line # noqa: PLR2004
|
298
|
+
log.info("WAVEGAIN: %s", line_trunc)
|
299
|
+
result.check_returncode()
|
300
|
+
|
301
|
+
@staticmethod
|
302
|
+
def _read_manifest(manifest_path: pathlib.Path) -> dict[Any, Any]:
|
303
|
+
with manifest_path.open(encoding="utf-8") as manifest_file:
|
304
|
+
return dict(yaml.safe_load(manifest_file))
|
305
|
+
|
306
|
+
def _rename_wav(self) -> None:
|
307
|
+
"""Rename the wav files to a filename-sane representation of the track title."""
|
308
|
+
for old_path in self._wav_filenames:
|
309
|
+
track_number = text.get_track_number(str(old_path.name))
|
310
|
+
title_filename = self._medium.tracks[track_number].get_filename(".wav")
|
311
|
+
new_path = old_path.parent / title_filename
|
312
|
+
if new_path.resolve() != old_path.resolve():
|
313
|
+
log.info("RENAMING: %s --> %s", old_path.name, new_path.name)
|
314
|
+
old_path.rename(new_path)
|
315
|
+
|
316
|
+
def _summary(self) -> tuple[str, bool]:
|
317
|
+
"""Return a summary of the conversion/tagging process and an "ok" flag indicating issues.
|
318
|
+
|
319
|
+
The summary is a nicely formatted table showing the album, artist and track info.
|
320
|
+
The "ok" flag indicating issues will be true if:
|
321
|
+
- the file count does not match the song count from the MusicBrainz database
|
322
|
+
(https://jrgraphix.net/r/Unicode/2500-257F)
|
323
|
+
"""
|
324
|
+
if self._audio_source is None or self._release is None:
|
325
|
+
warnings.warn(
|
326
|
+
"Cannot provide summary; missing audio_source and/or release.",
|
327
|
+
RuntimeWarning,
|
328
|
+
stacklevel=1,
|
329
|
+
)
|
330
|
+
return "", False
|
331
|
+
lines = []
|
332
|
+
okay = True
|
333
|
+
no_match = "(no match)"
|
334
|
+
col1 = [f.stem if f else no_match for f in self._audio_source.source_list]
|
335
|
+
col2 = [t.get_filename() for _, t in sorted(self._medium.tracks.items())]
|
336
|
+
col3 = [f"{str(n).zfill(2)}: {t.title}" for n, t in sorted(self._medium.tracks.items())]
|
337
|
+
min_total_w = 74 # Make sure we've got enough width for MB Release URL.
|
338
|
+
width = 40
|
339
|
+
col1_w = min(width, max([len(x) for x in col1] + [len(no_match)]))
|
340
|
+
col2_w = min(width, max([len(x) for x in col2] + [len(no_match)]))
|
341
|
+
col3_w = min(width, max([len(x) for x in col3] + [len(no_match)]))
|
342
|
+
if col1_w + col2_w + col3_w < min_total_w:
|
343
|
+
col3_w = min_total_w - (col1_w + col2_w)
|
344
|
+
tab_w = col1_w + col2_w + col3_w + 6
|
345
|
+
c1_line = (col1_w + 2) * "\u2550"
|
346
|
+
c2_line = (col2_w + 2) * "\u2550"
|
347
|
+
c3_line = (col3_w + 2) * "\u2550"
|
348
|
+
alb = f"Album: {self._release.album}"
|
349
|
+
art = f"Artist(s): {', '.join(self._release.album_artists_sort)}"
|
350
|
+
med = f"Disc: {self._disc_number} of {self._disc_count}"
|
351
|
+
mbr = f"MB Release: https://musicbrainz.org/release/{self._release.musicbrainz_album_id}"
|
352
|
+
lines.append(f"\u2554{c1_line}\u2550{c2_line}\u2550{c3_line}\u2557")
|
353
|
+
lines.extend(
|
354
|
+
[f"\u2551 {line} {' ' * (tab_w - len(line))}\u2551" for line in (alb, art, mbr, med)]
|
355
|
+
)
|
356
|
+
fmt = f"\u2551 {{c1: <{col1_w}}} \u2502 {{c2: <{col2_w}}} \u2502 {{c3: <{col3_w}}} \u2551"
|
357
|
+
lines.append(f"\u2560{c1_line}\u2564{c2_line}\u2564{c3_line}\u2563")
|
358
|
+
lines.append(fmt.format(c1="Source", c2="Destination", c3="Title"))
|
359
|
+
lines.append(f"\u2560{c1_line}\u256a{c2_line}\u256a{c3_line}\u2563")
|
360
|
+
rows = max(len(col1), len(col2), len(col3))
|
361
|
+
for i in range(rows):
|
362
|
+
col1_ = (
|
363
|
+
((col1[i][: width - 3] + "...") if len(col1[i]) > width else col1[i])
|
364
|
+
if len(col1) > i
|
365
|
+
else no_match
|
366
|
+
)
|
367
|
+
col2_ = (
|
368
|
+
((col2[i][: width - 3] + "...") if len(col2[i]) > width else col2[i])
|
369
|
+
if len(col2) > i
|
370
|
+
else no_match
|
371
|
+
)
|
372
|
+
col3_ = (
|
373
|
+
((col3[i][: width - 3] + "...") if len(col3[i]) > width else col3[i])
|
374
|
+
if len(col3) > i
|
375
|
+
else no_match
|
376
|
+
)
|
377
|
+
lines.append(fmt.format(c1=col1_, c2=col2_, c3=col3_))
|
378
|
+
if no_match in (col1_, col2_, col3_):
|
379
|
+
okay = False
|
380
|
+
lines.append(f"\u255a{c1_line}\u2567{c2_line}\u2567{c3_line}\u255d")
|
381
|
+
return "\n".join(lines), okay
|
382
|
+
|
383
|
+
def _tag_files(self, filenames: list[pathlib.Path]) -> None:
|
384
|
+
"""Tag the given list of files."""
|
385
|
+
for filename in filenames:
|
386
|
+
song = audiofile.AudioFile.open(filename)
|
387
|
+
song.one_track = records.OneTrack(
|
388
|
+
release=self._release,
|
389
|
+
medium_number=self._disc_number,
|
390
|
+
track_number=int(filename.name.split("__")[0]),
|
391
|
+
)
|
392
|
+
song.write_tags()
|
393
|
+
|
394
|
+
def _write_manifest(self) -> None:
|
395
|
+
"""Write out a manifest file with release information."""
|
396
|
+
release = self._release # We use this a lot below.
|
397
|
+
file_info = self._source_example.track.file_info
|
398
|
+
manifest = {
|
399
|
+
"album": release.album,
|
400
|
+
"artist": release.album_artists.first,
|
401
|
+
"artist_sort_name": release.album_artists_sort.first,
|
402
|
+
"media": release.media[self._disc_number].formats.first,
|
403
|
+
"genre": release.genres.first,
|
404
|
+
"disc_number": self._disc_number,
|
405
|
+
"disc_count": self._disc_count,
|
406
|
+
"original_year": release.original_year,
|
407
|
+
"date": release.date,
|
408
|
+
"musicbrainz_info": {
|
409
|
+
"albumid": release.musicbrainz_album_id,
|
410
|
+
"albumartistid": release.musicbrainz_album_artist_ids.first,
|
411
|
+
"releasegroupid": release.musicbrainz_release_group_id,
|
412
|
+
},
|
413
|
+
"source_info": {
|
414
|
+
"type": file_info.type.name,
|
415
|
+
"bitrate": file_info.bitrate,
|
416
|
+
"bitrate_mode": file_info.bitrate_mode.name,
|
417
|
+
},
|
418
|
+
}
|
419
|
+
if self.command == "manifest":
|
420
|
+
manifest_filename = self._manifest_file # Write to current directory.
|
421
|
+
if self._source_is_cd:
|
422
|
+
manifest["source_info"] = {
|
423
|
+
"type": "CD",
|
424
|
+
"bitrate": 1411,
|
425
|
+
"bitrate_mode": "CBR",
|
426
|
+
}
|
427
|
+
else:
|
428
|
+
source_dir = self._library_dir / "source"
|
429
|
+
artist_album_dir = self._release.get_artist_album_path()
|
430
|
+
manifest_filename = str(source_dir / artist_album_dir / self._manifest_file)
|
431
|
+
with pathlib.Path(manifest_filename).open("w", encoding="utf-8") as manifest_file:
|
432
|
+
yaml.dump(manifest, manifest_file)
|
433
|
+
print(f"Wrote {manifest_filename}")
|
audiolibrarian/cli.py
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
"""Audiolibrarian command line interface."""
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2021 Stephen Jibson
|
5
|
+
#
|
6
|
+
# This file is part of audiolibrarian.
|
7
|
+
#
|
8
|
+
# Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
|
9
|
+
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
|
10
|
+
# License, or (at your option) any later version.
|
11
|
+
#
|
12
|
+
# Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
13
|
+
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
14
|
+
# the GNU General Public License for more details.
|
15
|
+
#
|
16
|
+
# You should have received a copy of the GNU General Public License along with audiolibrarian.
|
17
|
+
# If not, see <https://www.gnu.org/licenses/>.
|
18
|
+
#
|
19
|
+
import argparse
|
20
|
+
import logging
|
21
|
+
import pathlib
|
22
|
+
import subprocess
|
23
|
+
import sys
|
24
|
+
from typing import Final
|
25
|
+
|
26
|
+
from audiolibrarian import commands
|
27
|
+
|
28
|
+
log = logging.getLogger("audiolibrarian")
|
29
|
+
|
30
|
+
|
31
|
+
class CommandLineInterface:
|
32
|
+
"""Command line interface."""
|
33
|
+
|
34
|
+
_REQUIRED_EXE: Final[set[str]] = {
|
35
|
+
"cd-paranoia",
|
36
|
+
"eject",
|
37
|
+
"faad",
|
38
|
+
"fdkaac",
|
39
|
+
"flac",
|
40
|
+
"lame",
|
41
|
+
"mpg123",
|
42
|
+
"sndfile-convert",
|
43
|
+
"wavegain",
|
44
|
+
}
|
45
|
+
|
46
|
+
def __init__(self, *, parse_args: bool = True) -> None:
|
47
|
+
"""Initialize a CommandLineInterface handler."""
|
48
|
+
if parse_args:
|
49
|
+
self._args = self._parse_args()
|
50
|
+
self.log_level = self._args.log_level
|
51
|
+
|
52
|
+
def execute(self) -> None:
|
53
|
+
"""Execute the command."""
|
54
|
+
log.info("ARGS: %s", self._args)
|
55
|
+
if not self._check_deps():
|
56
|
+
sys.exit(1)
|
57
|
+
for cmd in commands.COMMANDS:
|
58
|
+
if self._args.command == cmd.command:
|
59
|
+
if not cmd.validate_args(self._args):
|
60
|
+
sys.exit(2)
|
61
|
+
cmd(self._args)
|
62
|
+
break
|
63
|
+
if self._args.log_level == logging.DEBUG:
|
64
|
+
print(pathlib.Path("/proc/self/status").read_text(encoding="utf-8"))
|
65
|
+
|
66
|
+
def _check_deps(self) -> bool:
|
67
|
+
"""Check that all the executables defined in REQUIRED_EXE exist on the system.
|
68
|
+
|
69
|
+
If any of the required executables are missing, list them and return False.
|
70
|
+
"""
|
71
|
+
missing = []
|
72
|
+
for exe in self._REQUIRED_EXE:
|
73
|
+
try:
|
74
|
+
subprocess.run( # noqa: S602
|
75
|
+
f"command -v {exe}",
|
76
|
+
shell=True,
|
77
|
+
stdout=subprocess.DEVNULL,
|
78
|
+
stderr=subprocess.DEVNULL,
|
79
|
+
check=True,
|
80
|
+
)
|
81
|
+
except subprocess.CalledProcessError:
|
82
|
+
missing.append(exe)
|
83
|
+
if missing:
|
84
|
+
print(f"\nMissing required executable(s): {', '.join(missing)}\n")
|
85
|
+
return False
|
86
|
+
return True
|
87
|
+
|
88
|
+
# noinspection PyProtectedMember
|
89
|
+
@staticmethod
|
90
|
+
def _parse_args() -> argparse.Namespace:
|
91
|
+
"""Parse command line arguments."""
|
92
|
+
parser = argparse.ArgumentParser(description="audiolibrarian")
|
93
|
+
|
94
|
+
# global options
|
95
|
+
parser.add_argument(
|
96
|
+
"--log-level",
|
97
|
+
"-l",
|
98
|
+
choices=("CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"),
|
99
|
+
default="ERROR",
|
100
|
+
help="log level (default: ERROR)",
|
101
|
+
)
|
102
|
+
|
103
|
+
# Add sub-commands and args for sub_commands.
|
104
|
+
subparsers = parser.add_subparsers(title="commands", dest="command")
|
105
|
+
for cmd_ in commands.COMMANDS:
|
106
|
+
# This is a total hack because argparse won't allow you to add an already
|
107
|
+
# existing ArgumentParser as a sub-parser.
|
108
|
+
if cmd_.parser:
|
109
|
+
cmd_.parser.prog = f"{subparsers._prog_prefix} {cmd_.command}" # noqa: SLF001
|
110
|
+
subparsers._choices_actions.append( # noqa: SLF001
|
111
|
+
subparsers._ChoicesPseudoAction(cmd_.command, (), cmd_.help) # noqa: SLF001
|
112
|
+
)
|
113
|
+
subparsers._name_parser_map[cmd_.command] = cmd_.parser # noqa: SLF001
|
114
|
+
|
115
|
+
return parser.parse_args()
|
116
|
+
|
117
|
+
|
118
|
+
def main() -> None:
|
119
|
+
"""Execute the command line interface."""
|
120
|
+
cli_ = CommandLineInterface()
|
121
|
+
logging.basicConfig(level=cli_.log_level)
|
122
|
+
logging.captureWarnings(capture=True)
|
123
|
+
cli_.execute()
|