audiolibrarian 0.17.0__py3-none-any.whl → 0.18.0__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 +1 -1
- audiolibrarian/audiosource.py +3 -4
- audiolibrarian/base.py +17 -66
- audiolibrarian/commands.py +58 -17
- audiolibrarian/{settings.py → config.py} +53 -15
- audiolibrarian/entrypoints/__init__.py +17 -0
- audiolibrarian/{cli.py → entrypoints/cli.py} +2 -2
- audiolibrarian/genremanager.py +5 -5
- audiolibrarian/musicbrainz.py +27 -13
- audiolibrarian/normalizer.py +155 -0
- audiolibrarian/templates/config.toml +36 -0
- {audiolibrarian-0.17.0.dist-info → audiolibrarian-0.18.0.dist-info}/METADATA +1 -1
- audiolibrarian-0.18.0.dist-info/RECORD +31 -0
- audiolibrarian-0.18.0.dist-info/entry_points.txt +2 -0
- audiolibrarian-0.17.0.dist-info/RECORD +0 -28
- audiolibrarian-0.17.0.dist-info/entry_points.txt +0 -2
- {audiolibrarian-0.17.0.dist-info → audiolibrarian-0.18.0.dist-info}/WHEEL +0 -0
- {audiolibrarian-0.17.0.dist-info → audiolibrarian-0.18.0.dist-info}/licenses/COPYING +0 -0
- {audiolibrarian-0.17.0.dist-info → audiolibrarian-0.18.0.dist-info}/licenses/LICENSE +0 -0
audiolibrarian/__init__.py
CHANGED
audiolibrarian/audiosource.py
CHANGED
@@ -27,8 +27,7 @@ from collections.abc import Callable # noqa: TC003
|
|
27
27
|
|
28
28
|
import discid
|
29
29
|
|
30
|
-
from audiolibrarian import audiofile, records, sh, text
|
31
|
-
from audiolibrarian.settings import SETTINGS
|
30
|
+
from audiolibrarian import audiofile, config, records, sh, text
|
32
31
|
|
33
32
|
log = logging.getLogger(__name__)
|
34
33
|
|
@@ -93,10 +92,10 @@ class AudioSource(abc.ABC):
|
|
93
92
|
class CDAudioSource(AudioSource):
|
94
93
|
"""AudioSource from a compact disc."""
|
95
94
|
|
96
|
-
def __init__(self) -> None:
|
95
|
+
def __init__(self, settings: config.Settings) -> None:
|
97
96
|
"""Initialize a CDAudioSource."""
|
98
97
|
super().__init__()
|
99
|
-
self._cd = discid.read(
|
98
|
+
self._cd = discid.read(settings.discid_device or None, features=["mcn"])
|
100
99
|
|
101
100
|
def get_search_data(self) -> dict[str, str]:
|
102
101
|
"""Return a dictionary of search data useful for doing a MusicBrainz search."""
|
audiolibrarian/base.py
CHANGED
@@ -23,19 +23,25 @@ import argparse
|
|
23
23
|
import logging
|
24
24
|
import pathlib
|
25
25
|
import shutil
|
26
|
-
import subprocess
|
27
26
|
import sys
|
28
27
|
import warnings
|
29
28
|
from collections.abc import Iterable
|
30
29
|
from typing import Any, Final
|
31
30
|
|
32
31
|
import colors
|
33
|
-
import ffmpeg_normalize
|
34
32
|
import filelock
|
35
33
|
import yaml
|
36
34
|
|
37
|
-
from audiolibrarian import
|
38
|
-
|
35
|
+
from audiolibrarian import (
|
36
|
+
audiofile,
|
37
|
+
audiosource,
|
38
|
+
config,
|
39
|
+
musicbrainz,
|
40
|
+
normalizer,
|
41
|
+
records,
|
42
|
+
sh,
|
43
|
+
text,
|
44
|
+
)
|
39
45
|
|
40
46
|
log = logging.getLogger(__name__)
|
41
47
|
|
@@ -49,8 +55,9 @@ class Base:
|
|
49
55
|
command: str | None = None
|
50
56
|
_manifest_file: Final[str] = "Manifest.yaml"
|
51
57
|
|
52
|
-
def __init__(self, args: argparse.Namespace) -> None:
|
58
|
+
def __init__(self, args: argparse.Namespace, settings: config.Settings) -> None:
|
53
59
|
"""Initialize the base."""
|
60
|
+
self._settings = settings
|
54
61
|
# Pull in stuff from args.
|
55
62
|
search_keys = ("album", "artist", "mb_artist_id", "mb_release_id")
|
56
63
|
self._provided_search_data = {k: v for k, v in vars(args).items() if k in search_keys}
|
@@ -60,8 +67,8 @@ class Base:
|
|
60
67
|
self._disc_number, self._disc_count = 1, 1
|
61
68
|
|
62
69
|
# Directories.
|
63
|
-
self._library_dir =
|
64
|
-
self._work_dir =
|
70
|
+
self._library_dir = self._settings.library_dir
|
71
|
+
self._work_dir = self._settings.work_dir
|
65
72
|
self._flac_dir = self._work_dir / "flac"
|
66
73
|
self._m4a_dir = self._work_dir / "m4a"
|
67
74
|
self._mp3_dir = self._work_dir / "mp3"
|
@@ -70,7 +77,7 @@ class Base:
|
|
70
77
|
|
71
78
|
self._lock = filelock.FileLock(str(self._work_dir) + ".lock")
|
72
79
|
|
73
|
-
self._normalizer = self.
|
80
|
+
self._normalizer = normalizer.Normalizer.factory(self._settings.normalize)
|
74
81
|
|
75
82
|
# Initialize stuff that will be defined later.
|
76
83
|
self._audio_source: audiosource.AudioSource | None = None
|
@@ -143,7 +150,7 @@ class Base:
|
|
143
150
|
search_data: dict[str, str] = (
|
144
151
|
self._audio_source.get_search_data() if self._audio_source is not None else {}
|
145
152
|
)
|
146
|
-
searcher = musicbrainz.Searcher(**search_data) # type: ignore[arg-type]
|
153
|
+
searcher = musicbrainz.Searcher(settings=self._settings.musicbrainz, **search_data) # type: ignore[arg-type]
|
147
154
|
searcher.disc_number = str(self._disc_number)
|
148
155
|
# Override with user-provided info.
|
149
156
|
if value := self._provided_search_data.get("artist"):
|
@@ -269,35 +276,7 @@ class Base:
|
|
269
276
|
|
270
277
|
def _normalize(self) -> None:
|
271
278
|
"""Normalize the wav files using the selected normalizer."""
|
272
|
-
|
273
|
-
return
|
274
|
-
print(f"Normalizing wav files using {self._normalizer}...")
|
275
|
-
|
276
|
-
if self._normalizer == "wavegain":
|
277
|
-
command = [
|
278
|
-
"wavegain",
|
279
|
-
f"--{SETTINGS.normalize.wavegain.preset}",
|
280
|
-
f"--gain={SETTINGS.normalize.wavegain.gain}",
|
281
|
-
"--apply",
|
282
|
-
]
|
283
|
-
command.extend(str(f) for f in self._wav_filenames)
|
284
|
-
result = subprocess.run(command, capture_output=True, check=False) # noqa: S603
|
285
|
-
for line in str(result.stderr).split(r"\n"):
|
286
|
-
line_trunc = line[:137] + "..." if len(line) > 140 else line # noqa: PLR2004
|
287
|
-
log.info("WAVEGAIN: %s", line_trunc)
|
288
|
-
result.check_returncode()
|
289
|
-
return
|
290
|
-
|
291
|
-
normalizer = ffmpeg_normalize.FFmpegNormalize(
|
292
|
-
extension="wav",
|
293
|
-
keep_loudness_range_target=True,
|
294
|
-
target_level=SETTINGS.normalize.ffmpeg.target_level,
|
295
|
-
)
|
296
|
-
for wav_file in self._wav_filenames:
|
297
|
-
normalizer.add_media_file(str(wav_file), str(wav_file))
|
298
|
-
log.info("NORMALIZER: starting ffmpeg normalization...")
|
299
|
-
normalizer.run_normalization()
|
300
|
-
log.info("NORMALIZER: ffmpeg normalization completed successfully")
|
279
|
+
self._normalizer.normalize(set(self._wav_filenames))
|
301
280
|
|
302
281
|
def _rename_wav(self) -> None:
|
303
282
|
"""Rename the wav files to a filename-sane representation of the track title."""
|
@@ -428,34 +407,6 @@ class Base:
|
|
428
407
|
yaml.dump(manifest, manifest_file)
|
429
408
|
print(f"Wrote {manifest_filename}")
|
430
409
|
|
431
|
-
@staticmethod
|
432
|
-
def _which_normalizer() -> str:
|
433
|
-
"""Determine which normalizer to use based on settings and availability.
|
434
|
-
|
435
|
-
Returns:
|
436
|
-
str: The name of the normalizer to use ("wavegain", "ffmpeg" or "none")
|
437
|
-
"""
|
438
|
-
normalizer = SETTINGS.normalize.normalizer
|
439
|
-
if normalizer == "none":
|
440
|
-
return "none"
|
441
|
-
|
442
|
-
wavegain_found = shutil.which("wavegain")
|
443
|
-
if normalizer in ("auto", "wavegain") and wavegain_found:
|
444
|
-
return "wavegain"
|
445
|
-
|
446
|
-
ffmpeg_found = shutil.which("ffmpeg")
|
447
|
-
if normalizer in ("auto", "ffmpeg") and ffmpeg_found:
|
448
|
-
return "ffmpeg"
|
449
|
-
|
450
|
-
if wavegain_found:
|
451
|
-
log.warning("ffmpeg not found, using wavegain for normalization")
|
452
|
-
return "wavegain"
|
453
|
-
if ffmpeg_found:
|
454
|
-
log.warning("wavegain not found, using ffmpeg for normalization")
|
455
|
-
return "ffmpeg"
|
456
|
-
log.warning("wavegain not found, ffmpeg not found, using no normalization")
|
457
|
-
return "none"
|
458
|
-
|
459
410
|
@staticmethod
|
460
411
|
def _find_audio_files(directories: list[str | pathlib.Path]) -> Iterable[audiofile.AudioFile]:
|
461
412
|
"""Yield audiofile objects found in the given directories."""
|
audiolibrarian/commands.py
CHANGED
@@ -22,7 +22,7 @@ import pathlib
|
|
22
22
|
import re
|
23
23
|
from typing import Any
|
24
24
|
|
25
|
-
from audiolibrarian import __version__, audiofile, audiosource, base, genremanager
|
25
|
+
from audiolibrarian import __version__, audiofile, audiosource, base, config, genremanager
|
26
26
|
|
27
27
|
log = logging.getLogger(__name__)
|
28
28
|
|
@@ -39,6 +39,46 @@ class _Command:
|
|
39
39
|
return True
|
40
40
|
|
41
41
|
|
42
|
+
class Config(_Command, base.Base):
|
43
|
+
"""AudioLibrarian tool for working with a config file.
|
44
|
+
|
45
|
+
This class performs all of its tasks on instantiation and provides no public members or
|
46
|
+
methods.
|
47
|
+
"""
|
48
|
+
|
49
|
+
command = "config"
|
50
|
+
help = "work with the config file"
|
51
|
+
parser = argparse.ArgumentParser(description="Manage AudioLibrarian configuration")
|
52
|
+
parser.add_argument(
|
53
|
+
"--init",
|
54
|
+
"-i",
|
55
|
+
action="store_true",
|
56
|
+
help="initialize a new config file if it doesn't exist",
|
57
|
+
)
|
58
|
+
|
59
|
+
def __init__(self, args: argparse.Namespace, settings: config.Settings) -> None:
|
60
|
+
"""Initialize a Config command handler."""
|
61
|
+
super().__init__(args, settings)
|
62
|
+
|
63
|
+
if args.init:
|
64
|
+
try:
|
65
|
+
config.init_config_file()
|
66
|
+
except FileExistsError as e:
|
67
|
+
msg = f"Config file already exists at {config.CONFIG_PATH}"
|
68
|
+
raise SystemExit(msg) from e
|
69
|
+
except Exception:
|
70
|
+
log.exception("Error creating config file")
|
71
|
+
raise
|
72
|
+
print(f"Created new config file at {config.CONFIG_PATH}")
|
73
|
+
else:
|
74
|
+
print(f"Config file location: {config.CONFIG_PATH}")
|
75
|
+
if config.CONFIG_PATH.exists():
|
76
|
+
print("\n=== Config file contents ===")
|
77
|
+
print(config.CONFIG_PATH.read_text(encoding="utf-8"))
|
78
|
+
else:
|
79
|
+
print("Config file does not exist. Use '--init' to create it.")
|
80
|
+
|
81
|
+
|
42
82
|
class Convert(_Command, base.Base):
|
43
83
|
"""AudioLibrarian tool for converting and tagging audio files.
|
44
84
|
|
@@ -56,9 +96,9 @@ class Convert(_Command, base.Base):
|
|
56
96
|
parser.add_argument("--disc", "-d", help="format: x/y: disc x of y for multi-disc release")
|
57
97
|
parser.add_argument("filename", nargs="+", help="directory name or audio file name")
|
58
98
|
|
59
|
-
def __init__(self, args: argparse.Namespace) -> None:
|
99
|
+
def __init__(self, args: argparse.Namespace, settings: config.Settings) -> None:
|
60
100
|
"""Initialize a Convert command handler."""
|
61
|
-
super().__init__(args)
|
101
|
+
super().__init__(args, settings)
|
62
102
|
self._source_is_cd = False
|
63
103
|
self._audio_source = audiosource.FilesAudioSource([pathlib.Path(x) for x in args.filename])
|
64
104
|
self._get_tag_info()
|
@@ -88,9 +128,9 @@ class Genre(_Command):
|
|
88
128
|
parser_action.add_argument("--tag", action="store_true", help="update tags")
|
89
129
|
parser_action.add_argument("--update", action="store_true", help="update MusicBrainz")
|
90
130
|
|
91
|
-
def __init__(self, args: argparse.Namespace) -> None:
|
131
|
+
def __init__(self, args: argparse.Namespace, settings: config.Settings) -> None:
|
92
132
|
"""Initialize a Genre command handler."""
|
93
|
-
genremanager.GenreManager(args)
|
133
|
+
genremanager.GenreManager(args=args, settings=settings.musicbrainz)
|
94
134
|
|
95
135
|
|
96
136
|
class Manifest(_Command, base.Base):
|
@@ -111,9 +151,9 @@ class Manifest(_Command, base.Base):
|
|
111
151
|
parser.add_argument("--disc", "-d", help="format: x/y: disc x of y for multi-disc release")
|
112
152
|
parser.add_argument("filename", nargs="+", help="directory name or audio file name")
|
113
153
|
|
114
|
-
def __init__(self, args: argparse.Namespace) -> None:
|
154
|
+
def __init__(self, args: argparse.Namespace, settings: config.Settings) -> None:
|
115
155
|
"""Initialize a Manifest command handler."""
|
116
|
-
super().__init__(args)
|
156
|
+
super().__init__(args, settings)
|
117
157
|
self._source_is_cd = args.cd
|
118
158
|
self._audio_source = audiosource.FilesAudioSource([pathlib.Path(x) for x in args.filename])
|
119
159
|
source_filenames = self._audio_source.get_source_filenames()
|
@@ -139,9 +179,9 @@ class Reconvert(_Command, base.Base):
|
|
139
179
|
parser = argparse.ArgumentParser()
|
140
180
|
parser.add_argument("directories", nargs="+", help="source directories")
|
141
181
|
|
142
|
-
def __init__(self, args: argparse.Namespace) -> None:
|
182
|
+
def __init__(self, args: argparse.Namespace, settings: config.Settings) -> None:
|
143
183
|
"""Initialize a Reconvert command handler."""
|
144
|
-
super().__init__(args)
|
184
|
+
super().__init__(args, settings)
|
145
185
|
self._source_is_cd = False
|
146
186
|
manifest_paths = self._find_manifests(args.directories)
|
147
187
|
count = len(manifest_paths)
|
@@ -172,9 +212,9 @@ class Rename(_Command, base.Base):
|
|
172
212
|
parser.add_argument("--dry-run", action="store_true", help="don't actually rename files")
|
173
213
|
parser.add_argument("directories", nargs="+", help="audio file directories")
|
174
214
|
|
175
|
-
def __init__(self, args: argparse.Namespace) -> None:
|
215
|
+
def __init__(self, args: argparse.Namespace, settings: config.Settings) -> None:
|
176
216
|
"""Initialize a Rename command handler."""
|
177
|
-
super().__init__(args)
|
217
|
+
super().__init__(args, settings)
|
178
218
|
self._source_is_cd = False
|
179
219
|
print("Finding audio files...")
|
180
220
|
for audio_file in self._find_audio_files(args.directories):
|
@@ -233,11 +273,11 @@ class Rip(_Command, base.Base):
|
|
233
273
|
parser.add_argument("--mb-release-id", help="MusicBrainz release ID")
|
234
274
|
parser.add_argument("--disc", "-d", help="x/y: disc x of y; multi-disc release")
|
235
275
|
|
236
|
-
def __init__(self, args: argparse.Namespace) -> None:
|
276
|
+
def __init__(self, args: argparse.Namespace, settings: config.Settings) -> None:
|
237
277
|
"""Initialize a Rip command handler."""
|
238
|
-
super().__init__(args)
|
278
|
+
super().__init__(args, settings)
|
239
279
|
self._source_is_cd = True
|
240
|
-
self._audio_source = audiosource.CDAudioSource()
|
280
|
+
self._audio_source = audiosource.CDAudioSource(settings)
|
241
281
|
self._get_tag_info()
|
242
282
|
self._convert()
|
243
283
|
self._write_manifest()
|
@@ -254,9 +294,10 @@ class Version(_Command):
|
|
254
294
|
command = "version"
|
255
295
|
help = "display the program version"
|
256
296
|
|
257
|
-
def __init__(self, args: argparse.Namespace) -> None:
|
297
|
+
def __init__(self, args: argparse.Namespace, settings: config.Settings) -> None:
|
258
298
|
"""Initialize a Version command handler."""
|
259
|
-
_ = args
|
299
|
+
_, _ = args, settings
|
300
|
+
del args, settings # Unused.
|
260
301
|
print(f"audiolibrarian {__version__}")
|
261
302
|
|
262
303
|
|
@@ -280,4 +321,4 @@ def _validate_disc_arg(args: argparse.Namespace) -> bool:
|
|
280
321
|
return True
|
281
322
|
|
282
323
|
|
283
|
-
COMMANDS: set[Any] = {Convert, Genre, Manifest, Reconvert, Rename, Rip, Version}
|
324
|
+
COMMANDS: set[Any] = {Config, Convert, Genre, Manifest, Reconvert, Rename, Rip, Version}
|
@@ -7,8 +7,8 @@ sources of configuration:
|
|
7
7
|
- Prefix: "AUDIOLIBRARIAN__"
|
8
8
|
- Nested fields: Use "__" as delimiter (e.g., AUDIOLIBRARIAN__MUSICBRAINZ__USERNAME)
|
9
9
|
|
10
|
-
2.
|
11
|
-
- Location: $XDG_CONFIG_HOME/audiolibrarian/config.
|
10
|
+
2. TOML Configuration File:
|
11
|
+
- Location: $XDG_CONFIG_HOME/audiolibrarian/config.toml
|
12
12
|
- Supports nested structure for MusicBrainz settings
|
13
13
|
|
14
14
|
3. Default Values:
|
@@ -39,37 +39,51 @@ Sensitive fields (like passwords) are handled using pydantic.SecretStr for secur
|
|
39
39
|
#
|
40
40
|
import logging
|
41
41
|
import pathlib
|
42
|
-
from typing import Literal
|
42
|
+
from typing import Annotated, Final, Literal
|
43
43
|
|
44
44
|
import pydantic
|
45
45
|
import pydantic_settings
|
46
46
|
import xdg_base_dirs
|
47
47
|
|
48
|
+
CONFIG_PATH: Final[pathlib.Path] = (
|
49
|
+
xdg_base_dirs.xdg_config_home() / "audiolibrarian" / "config.toml"
|
50
|
+
)
|
51
|
+
|
48
52
|
logger = logging.getLogger(__name__)
|
49
53
|
|
54
|
+
ExpandedPath = Annotated[
|
55
|
+
pathlib.Path,
|
56
|
+
pydantic.AfterValidator(lambda v: v.expanduser()),
|
57
|
+
]
|
58
|
+
|
59
|
+
|
60
|
+
class EmptySettings(pydantic.BaseModel):
|
61
|
+
"""Empty settings."""
|
50
62
|
|
51
|
-
|
63
|
+
|
64
|
+
class MusicBrainzSettings(pydantic.BaseModel):
|
52
65
|
"""Configuration settings for MusicBrainz."""
|
53
66
|
|
54
67
|
password: pydantic.SecretStr = pydantic.SecretStr("")
|
55
68
|
username: str = ""
|
56
69
|
rate_limit: pydantic.PositiveFloat = 1.5 # Seconds between requests.
|
70
|
+
work_dir: ExpandedPath = xdg_base_dirs.xdg_cache_home() / "audiolibrarian"
|
57
71
|
|
58
72
|
|
59
|
-
class NormalizeFFmpegSettings(
|
73
|
+
class NormalizeFFmpegSettings(pydantic.BaseModel):
|
60
74
|
"""Configuration settings for ffmpeg normalization."""
|
61
75
|
|
62
76
|
target_level: float = -13
|
63
77
|
|
64
78
|
|
65
|
-
class NormalizeWavegainSettings(
|
79
|
+
class NormalizeWavegainSettings(pydantic.BaseModel):
|
66
80
|
"""Configuration settings for wavegain normalization."""
|
67
81
|
|
68
82
|
gain: int = 5 # dB
|
69
83
|
preset: Literal["album", "radio"] = "radio"
|
70
84
|
|
71
85
|
|
72
|
-
class NormalizeSettings(
|
86
|
+
class NormalizeSettings(pydantic.BaseModel):
|
73
87
|
"""Configuration settings for audio normalization."""
|
74
88
|
|
75
89
|
normalizer: Literal["auto", "wavegain", "ffmpeg", "none"] = "auto"
|
@@ -80,16 +94,16 @@ class NormalizeSettings(pydantic_settings.BaseSettings):
|
|
80
94
|
class Settings(pydantic_settings.BaseSettings):
|
81
95
|
"""Configuration settings for AudioLibrarian."""
|
82
96
|
|
83
|
-
discid_device: str
|
84
|
-
library_dir:
|
97
|
+
discid_device: str = "" # Use default device.
|
98
|
+
library_dir: ExpandedPath = pathlib.Path("library").resolve()
|
85
99
|
musicbrainz: MusicBrainzSettings = MusicBrainzSettings()
|
86
100
|
normalize: NormalizeSettings = NormalizeSettings()
|
87
|
-
work_dir:
|
101
|
+
work_dir: ExpandedPath = xdg_base_dirs.xdg_cache_home() / "audiolibrarian"
|
88
102
|
|
89
103
|
model_config = pydantic_settings.SettingsConfigDict(
|
90
104
|
env_nested_delimiter="__",
|
91
105
|
env_prefix="AUDIOLIBRARIAN__",
|
92
|
-
|
106
|
+
toml_file=str(CONFIG_PATH),
|
93
107
|
frozen=True, # Make settings immutable.
|
94
108
|
)
|
95
109
|
|
@@ -102,13 +116,37 @@ class Settings(pydantic_settings.BaseSettings):
|
|
102
116
|
dotenv_settings: pydantic_settings.PydanticBaseSettingsSource,
|
103
117
|
file_secret_settings: pydantic_settings.PydanticBaseSettingsSource,
|
104
118
|
) -> tuple[pydantic_settings.PydanticBaseSettingsSource, ...]:
|
119
|
+
"""Customize settings sources."""
|
105
120
|
del dotenv_settings, file_secret_settings # Unused.
|
106
121
|
return (
|
122
|
+
init_settings, # Used for tests.
|
107
123
|
env_settings,
|
108
|
-
pydantic_settings.
|
109
|
-
init_settings,
|
124
|
+
pydantic_settings.TomlConfigSettingsSource(settings_cls),
|
110
125
|
)
|
111
126
|
|
112
127
|
|
113
|
-
|
114
|
-
|
128
|
+
def init_config_file() -> None:
|
129
|
+
"""Initialize a new config file with default values.
|
130
|
+
|
131
|
+
The config file will be created at the location specified by CONFIG_PATH.
|
132
|
+
If the parent directory doesn't exist, it will be created.
|
133
|
+
|
134
|
+
Raises:
|
135
|
+
FileExistsError: If the config file already exists
|
136
|
+
OSError: If there's an error creating the file or directories
|
137
|
+
FileNotFoundError: If the template file cannot be found
|
138
|
+
"""
|
139
|
+
if CONFIG_PATH.exists():
|
140
|
+
msg = f"Config file already exists at {CONFIG_PATH}"
|
141
|
+
logger.warning(msg)
|
142
|
+
raise FileExistsError(msg)
|
143
|
+
|
144
|
+
# Create the config directory if it doesn't exist
|
145
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
146
|
+
|
147
|
+
# Get the directory where this file is located
|
148
|
+
template_dir = pathlib.Path(__file__).parent / "templates"
|
149
|
+
template_file = template_dir / CONFIG_PATH.name
|
150
|
+
|
151
|
+
# Read the template and write to the config file
|
152
|
+
CONFIG_PATH.write_text(template_file.read_text(encoding="utf-8"), encoding="utf-8")
|
@@ -0,0 +1,17 @@
|
|
1
|
+
"""Entrypoints for audiolibrarian."""
|
2
|
+
#
|
3
|
+
# Copyright (c) 2000-2025 Stephen Jibson
|
4
|
+
#
|
5
|
+
# This file is part of audiolibrarian.
|
6
|
+
#
|
7
|
+
# Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
|
8
|
+
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
|
9
|
+
# License, or (at your option) any later version.
|
10
|
+
#
|
11
|
+
# Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
12
|
+
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
13
|
+
# the GNU General Public License for more details.
|
14
|
+
#
|
15
|
+
# You should have received a copy of the GNU General Public License along with audiolibrarian.
|
16
|
+
# If not, see <https://www.gnu.org/licenses/>.
|
17
|
+
#
|
@@ -23,7 +23,7 @@ import subprocess
|
|
23
23
|
import sys
|
24
24
|
from typing import Final
|
25
25
|
|
26
|
-
from audiolibrarian import commands
|
26
|
+
from audiolibrarian import commands, config
|
27
27
|
|
28
28
|
log = logging.getLogger("audiolibrarian")
|
29
29
|
|
@@ -57,7 +57,7 @@ class CommandLineInterface:
|
|
57
57
|
if self._args.command == cmd.command:
|
58
58
|
if not cmd.validate_args(self._args):
|
59
59
|
sys.exit(2)
|
60
|
-
cmd(self._args)
|
60
|
+
cmd(self._args, config.Settings())
|
61
61
|
break
|
62
62
|
if self._args.log_level == logging.DEBUG:
|
63
63
|
print(pathlib.Path("/proc/self/status").read_text(encoding="utf-8"))
|
audiolibrarian/genremanager.py
CHANGED
@@ -29,8 +29,7 @@ import mutagen.flac
|
|
29
29
|
import mutagen.id3
|
30
30
|
import mutagen.mp4
|
31
31
|
|
32
|
-
from audiolibrarian import musicbrainz, text
|
33
|
-
from audiolibrarian.settings import SETTINGS
|
32
|
+
from audiolibrarian import config, musicbrainz, text
|
34
33
|
|
35
34
|
log = logging.getLogger(__name__)
|
36
35
|
|
@@ -38,10 +37,11 @@ log = logging.getLogger(__name__)
|
|
38
37
|
class GenreManager:
|
39
38
|
"""Manage genres."""
|
40
39
|
|
41
|
-
def __init__(self, args: argparse.Namespace) -> None:
|
40
|
+
def __init__(self, args: argparse.Namespace, settings: config.MusicBrainzSettings) -> None:
|
42
41
|
"""Initialize a GenreManager instance."""
|
43
42
|
self._args = args
|
44
|
-
self.
|
43
|
+
self._settings = settings
|
44
|
+
self._mb = musicbrainz.MusicBrainzSession(settings=settings)
|
45
45
|
self._paths = self._get_all_paths()
|
46
46
|
self._paths_by_artist = self._get_paths_by_artist()
|
47
47
|
_u, _c = self._get_genres_by_artist()
|
@@ -149,7 +149,7 @@ class GenreManager:
|
|
149
149
|
user: dict[str, str] = {}
|
150
150
|
community: dict[str, dict[str, Any]] = {}
|
151
151
|
user_modified = False
|
152
|
-
cache_file =
|
152
|
+
cache_file = self._settings.work_dir / "user-genres.pickle"
|
153
153
|
if cache_file.exists():
|
154
154
|
with cache_file.open(mode="rb") as cache_file_obj:
|
155
155
|
user = pickle.load(cache_file_obj) # noqa: S301
|
audiolibrarian/musicbrainz.py
CHANGED
@@ -30,8 +30,7 @@ import requests
|
|
30
30
|
from fuzzywuzzy import fuzz
|
31
31
|
from requests import auth
|
32
32
|
|
33
|
-
from audiolibrarian import __version__, records, text
|
34
|
-
from audiolibrarian.settings import SETTINGS
|
33
|
+
from audiolibrarian import __version__, config, records, text
|
35
34
|
|
36
35
|
if TYPE_CHECKING:
|
37
36
|
from collections.abc import Callable
|
@@ -48,11 +47,11 @@ class MusicBrainzSession:
|
|
48
47
|
It can be for things that are not supported by the musicbrainzngs library.
|
49
48
|
"""
|
50
49
|
|
51
|
-
_api_rate = dt.timedelta(seconds=SETTINGS.musicbrainz.rate_limit)
|
52
50
|
_last_api_call = dt.datetime.now(tz=dt.UTC)
|
53
51
|
|
54
|
-
def __init__(self) -> None:
|
52
|
+
def __init__(self, settings: config.MusicBrainzSettings) -> None:
|
55
53
|
"""Initialize a MusicBrainzSession."""
|
54
|
+
self._settings = settings
|
56
55
|
self.__session: requests.Session | None = None
|
57
56
|
|
58
57
|
def __del__(self) -> None:
|
@@ -65,8 +64,8 @@ class MusicBrainzSession:
|
|
65
64
|
if self.__session is None:
|
66
65
|
self.__session = requests.Session()
|
67
66
|
|
68
|
-
if (username :=
|
69
|
-
password :=
|
67
|
+
if (username := self._settings.username) and (
|
68
|
+
password := self._settings.password.get_secret_value()
|
70
69
|
):
|
71
70
|
self._session.auth = auth.HTTPDigestAuth(username, password)
|
72
71
|
self._session.headers.update(
|
@@ -108,14 +107,17 @@ class MusicBrainzSession:
|
|
108
107
|
params["inc"] = "+".join(includes)
|
109
108
|
return self._get(f"release-group/{release_group_id}", params=params)
|
110
109
|
|
111
|
-
|
112
|
-
def sleep() -> None:
|
110
|
+
def sleep(self) -> None:
|
113
111
|
"""Sleep so we don't abuse the MusicBrainz API service.
|
114
112
|
|
115
113
|
See https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting
|
116
114
|
"""
|
117
115
|
since_last = dt.datetime.now(tz=dt.UTC) - MusicBrainzSession._last_api_call
|
118
|
-
if (
|
116
|
+
if (
|
117
|
+
sleep_seconds := (
|
118
|
+
dt.timedelta(seconds=self._settings.rate_limit) - since_last
|
119
|
+
).total_seconds()
|
120
|
+
) > 0:
|
119
121
|
log.debug("Sleeping %s to avoid throttling...", sleep_seconds)
|
120
122
|
time.sleep(sleep_seconds)
|
121
123
|
MusicBrainzSession._last_api_call = dt.datetime.now(tz=dt.UTC)
|
@@ -137,11 +139,17 @@ class MusicBrainzRelease:
|
|
137
139
|
"work-level-rels",
|
138
140
|
]
|
139
141
|
|
140
|
-
def __init__(
|
142
|
+
def __init__(
|
143
|
+
self,
|
144
|
+
release_id: str,
|
145
|
+
settings: config.MusicBrainzSettings,
|
146
|
+
*,
|
147
|
+
verbose: bool = False,
|
148
|
+
) -> None:
|
141
149
|
"""Initialize an MusicBrainzRelease."""
|
142
150
|
self._release_id = release_id
|
143
151
|
self._verbose = verbose
|
144
|
-
self._session = MusicBrainzSession()
|
152
|
+
self._session = MusicBrainzSession(settings=settings)
|
145
153
|
self._session.sleep()
|
146
154
|
self._release = mb.get_release_by_id(release_id, includes=self._includes)["release"]
|
147
155
|
self._release_record: records.Release | None = None
|
@@ -392,10 +400,16 @@ class Searcher:
|
|
392
400
|
mb_release_id: str = ""
|
393
401
|
__mb_session: MusicBrainzSession | None = None
|
394
402
|
|
403
|
+
settings: dataclasses.InitVar[config.MusicBrainzSettings] = None
|
404
|
+
|
405
|
+
def __post_init__(self, settings: config.MusicBrainzSettings) -> None:
|
406
|
+
"""Process additional variables."""
|
407
|
+
self._settings = settings
|
408
|
+
|
395
409
|
@property
|
396
410
|
def _mb_session(self) -> MusicBrainzSession:
|
397
411
|
if self.__mb_session is None:
|
398
|
-
self.__mb_session = MusicBrainzSession()
|
412
|
+
self.__mb_session = MusicBrainzSession(settings=self._settings)
|
399
413
|
return self.__mb_session
|
400
414
|
|
401
415
|
def find_music_brains_release(self) -> records.Release | None:
|
@@ -419,7 +433,7 @@ class Searcher:
|
|
419
433
|
else:
|
420
434
|
release_id = self._prompt_uuid("MusicBrainz Release ID: ")
|
421
435
|
|
422
|
-
return MusicBrainzRelease(release_id).get_release()
|
436
|
+
return MusicBrainzRelease(release_id=release_id, settings=self._settings).get_release()
|
423
437
|
|
424
438
|
def _get_release_group_ids(self) -> list[str]:
|
425
439
|
# Return release groups that fuzzy-match the search criteria.
|
@@ -0,0 +1,155 @@
|
|
1
|
+
"""Audio normalization functionality using different backends."""
|
2
|
+
#
|
3
|
+
# Copyright (c) 2000-2025 Stephen Jibson
|
4
|
+
#
|
5
|
+
# This file is part of audiolibrarian.
|
6
|
+
#
|
7
|
+
# Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
|
8
|
+
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
|
9
|
+
# License, or (at your option) any later version.
|
10
|
+
#
|
11
|
+
# Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
12
|
+
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
13
|
+
# the GNU General Public License for more details.
|
14
|
+
#
|
15
|
+
# You should have received a copy of the GNU General Public License along with audiolibrarian.`
|
16
|
+
# If not, see <https://www.gnu.org/licenses/>.
|
17
|
+
#
|
18
|
+
|
19
|
+
import abc
|
20
|
+
import logging
|
21
|
+
import pathlib
|
22
|
+
import shutil
|
23
|
+
import subprocess
|
24
|
+
from typing import Any, TypeVar
|
25
|
+
|
26
|
+
import ffmpeg_normalize
|
27
|
+
import pydantic
|
28
|
+
|
29
|
+
from audiolibrarian import config
|
30
|
+
|
31
|
+
log = logging.getLogger(__name__)
|
32
|
+
|
33
|
+
T = TypeVar("T", bound=pydantic.BaseModel)
|
34
|
+
|
35
|
+
|
36
|
+
class Normalizer[T](abc.ABC):
|
37
|
+
"""Abstract base class for audio normalizers."""
|
38
|
+
|
39
|
+
def __init__(self, settings: T) -> None:
|
40
|
+
"""Initialize a Normalizer instance.
|
41
|
+
|
42
|
+
Args:
|
43
|
+
settings: The settings specific to this normalizer type.
|
44
|
+
"""
|
45
|
+
self._settings: T = settings
|
46
|
+
|
47
|
+
@classmethod
|
48
|
+
def factory(cls, settings: config.NormalizeSettings) -> "Normalizer[Any]":
|
49
|
+
"""Create the appropriate normalizer based on settings.
|
50
|
+
|
51
|
+
Args:
|
52
|
+
settings: The normalization settings.
|
53
|
+
|
54
|
+
Returns:
|
55
|
+
An instance of the appropriate Normalizer implementation.
|
56
|
+
"""
|
57
|
+
if settings.normalizer == "none":
|
58
|
+
return NoOpNormalizer(config.EmptySettings())
|
59
|
+
|
60
|
+
wavegain_found = shutil.which("wavegain")
|
61
|
+
if settings.normalizer in ("auto", "wavegain") and wavegain_found:
|
62
|
+
return WaveGainNormalizer(settings.wavegain)
|
63
|
+
|
64
|
+
ffmpeg_found = shutil.which("ffmpeg")
|
65
|
+
if settings.normalizer in ("auto", "ffmpeg") and ffmpeg_found:
|
66
|
+
return FFmpegNormalizer(settings.ffmpeg)
|
67
|
+
|
68
|
+
if wavegain_found:
|
69
|
+
log.warning("ffmpeg not found, using wavegain for normalization")
|
70
|
+
return WaveGainNormalizer(settings.wavegain)
|
71
|
+
if ffmpeg_found:
|
72
|
+
log.warning("wavegain not found, using ffmpeg for normalization")
|
73
|
+
return FFmpegNormalizer(settings.ffmpeg)
|
74
|
+
log.warning("wavegain not found, ffmpeg not found, using no normalization")
|
75
|
+
return NoOpNormalizer(config.EmptySettings())
|
76
|
+
|
77
|
+
@abc.abstractmethod
|
78
|
+
def normalize(self, paths: set[pathlib.Path]) -> None:
|
79
|
+
"""Normalize the given audio files.
|
80
|
+
|
81
|
+
Args:
|
82
|
+
paths: Set of audio file paths to normalize.
|
83
|
+
|
84
|
+
Raises:
|
85
|
+
Exception: If the normalization process fails.
|
86
|
+
"""
|
87
|
+
|
88
|
+
|
89
|
+
class NoOpNormalizer(Normalizer[config.EmptySettings]):
|
90
|
+
"""No-op normalizer that does nothing."""
|
91
|
+
|
92
|
+
def normalize(self, paths: set[pathlib.Path]) -> None:
|
93
|
+
"""Do not perform any normalization."""
|
94
|
+
del paths # Unused.
|
95
|
+
log.info("Skipping audio normalization")
|
96
|
+
|
97
|
+
|
98
|
+
class WaveGainNormalizer(Normalizer[config.NormalizeWavegainSettings]):
|
99
|
+
"""Audio normalizer using wavegain."""
|
100
|
+
|
101
|
+
def normalize(self, paths: set[pathlib.Path]) -> None:
|
102
|
+
"""Normalize audio files using wavegain.
|
103
|
+
|
104
|
+
Args:
|
105
|
+
paths: List of audio file paths to normalize.
|
106
|
+
|
107
|
+
Raises:
|
108
|
+
subprocess.CalledProcessError: If wavegain process fails.
|
109
|
+
"""
|
110
|
+
if not paths:
|
111
|
+
return
|
112
|
+
|
113
|
+
log.info("Normalizing %d files with wavegain...", len(paths))
|
114
|
+
|
115
|
+
command = [
|
116
|
+
"wavegain",
|
117
|
+
f"--{self._settings.preset}",
|
118
|
+
f"--gain={self._settings.gain}",
|
119
|
+
"--apply",
|
120
|
+
*[str(f) for f in paths],
|
121
|
+
]
|
122
|
+
result = subprocess.run(command, capture_output=True, check=False) # noqa: S603
|
123
|
+
for line in str(result.stderr).split(r"\n"):
|
124
|
+
line_trunc = line[:137] + "..." if len(line) > 140 else line # noqa: PLR2004
|
125
|
+
log.info("WAVEGAIN: %s", line_trunc)
|
126
|
+
result.check_returncode() # May raise subprocess.CalledProcessError.
|
127
|
+
|
128
|
+
|
129
|
+
class FFmpegNormalizer(Normalizer[config.NormalizeFFmpegSettings]):
|
130
|
+
"""Audio normalizer using ffmpeg-normalize."""
|
131
|
+
|
132
|
+
def normalize(self, paths: set[pathlib.Path]) -> None:
|
133
|
+
"""Normalize audio files using ffmpeg-normalize.
|
134
|
+
|
135
|
+
Args:
|
136
|
+
paths: List of audio file paths to normalize.
|
137
|
+
|
138
|
+
Raises:
|
139
|
+
Exception: If ffmpeg-normalize process fails.
|
140
|
+
"""
|
141
|
+
if not paths:
|
142
|
+
return
|
143
|
+
|
144
|
+
log.info("Normalizing %d files with ffmpeg-normalize...", len(paths))
|
145
|
+
|
146
|
+
normalizer = ffmpeg_normalize.FFmpegNormalize(
|
147
|
+
extension="wav",
|
148
|
+
keep_loudness_range_target=True,
|
149
|
+
target_level=self._settings.target_level,
|
150
|
+
)
|
151
|
+
for path in paths:
|
152
|
+
normalizer.add_media_file(str(path), str(path))
|
153
|
+
log.info("Starting ffmpeg normalization...")
|
154
|
+
normalizer.run_normalization()
|
155
|
+
log.info("FFmpeg normalization completed successfully")
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# AudioLibrarian Configuration
|
2
|
+
#
|
3
|
+
# This file was automatically generated. Modify as needed.
|
4
|
+
#
|
5
|
+
|
6
|
+
# Path to your music library
|
7
|
+
# library_dir = "~/Music/Library"
|
8
|
+
|
9
|
+
# Disc ID device (default: system default)
|
10
|
+
# discid_device = "/dev/sr0"
|
11
|
+
|
12
|
+
# Working directory for temporary files
|
13
|
+
# work_dir = "~/.cache/audiolibrarian"
|
14
|
+
|
15
|
+
|
16
|
+
[musicbrainz]
|
17
|
+
# MusicBrainz username and password (optional)
|
18
|
+
# username = ""
|
19
|
+
# password = "" # Will be stored in plain text!
|
20
|
+
|
21
|
+
# Rate limit in seconds between requests
|
22
|
+
# rate_limit = 1.5
|
23
|
+
|
24
|
+
[normalize]
|
25
|
+
# Normalizer to use: "auto", "wavegain", "ffmpeg", or "none"
|
26
|
+
# normalizer = "auto"
|
27
|
+
|
28
|
+
[normalize.ffmpeg]
|
29
|
+
# Target level in dB for ffmpeg normalization
|
30
|
+
# target_level = -13.0
|
31
|
+
|
32
|
+
[normalize.wavegain]
|
33
|
+
# Album gain preset: "album" or "radio"
|
34
|
+
# preset = "radio"
|
35
|
+
# Gain in dB for wavegain
|
36
|
+
# gain = 5
|
@@ -0,0 +1,31 @@
|
|
1
|
+
audiolibrarian/__init__.py,sha256=y8iHtIRKpBt2gg-NJssr_wBkpo_qMXNDzyugA5QXUi0,792
|
2
|
+
audiolibrarian/audiosource.py,sha256=UvTU2XBY_-B4bBCOh4Dl7xn1hQ2KUPcW1jjoEGMTe-Q,8548
|
3
|
+
audiolibrarian/base.py,sha256=H-lX1A-ve950AY6SAlwBlLmKBQT5WU_fHbvNRfOYhes,19118
|
4
|
+
audiolibrarian/commands.py,sha256=QEmKp0OYlg6XVcFi8tZ8XUj-X53xRCk7LILmBMV8fr0,12990
|
5
|
+
audiolibrarian/config.py,sha256=OjHxyslIOkOsaALC3zd6JtmXxz3HPBb2iaDFvmyRweA,5345
|
6
|
+
audiolibrarian/genremanager.py,sha256=cwHXAQ44toSe_ZSOix8iSaHkVyyXuy62TjcjOUHvMA0,7420
|
7
|
+
audiolibrarian/musicbrainz.py,sha256=pLgj0Z2PmwPVBw0WTLDDX8vGoV4cfKLTI3cLkK7r7NU,19712
|
8
|
+
audiolibrarian/normalizer.py,sha256=TaQcSsFkJqSTbNTfjFm4YcXAm9-T1uTcAa75pwJ2W-Y,5213
|
9
|
+
audiolibrarian/output.py,sha256=MQGsKrOfkRASGkVG4CEi2kgj0smEm8TfXqAR-lwULwk,1707
|
10
|
+
audiolibrarian/records.py,sha256=87DXbpwzl8Fsh71Nzi5XmJDAaEGvRjH6vwJ75cIM9PQ,7934
|
11
|
+
audiolibrarian/sh.py,sha256=7V-FrSSiq56cNL1IyYBEcSUXtMbwFs6Ob1EhU_i_c84,1917
|
12
|
+
audiolibrarian/text.py,sha256=VrxrQm6pZtuYbK9MlukSC3MdqDBSlsnTgkhBTwj9MpY,4031
|
13
|
+
audiolibrarian/audiofile/__init__.py,sha256=PTBAVV5gfSc0UVvWMHyeL1d3xwhgJ8J00L-KVhuCGhY,898
|
14
|
+
audiolibrarian/audiofile/audiofile.py,sha256=UCVXu2ga7stnTzeeLzrx-JTFl5ad82qy45HmB3g2a8c,4357
|
15
|
+
audiolibrarian/audiofile/tags.py,sha256=Di8TjvOYZYYgedMNRZ8tTGFxZ1KN4oAK7z17TzTSeZg,1790
|
16
|
+
audiolibrarian/audiofile/formats/__init__.py,sha256=DexI0KdI6hnbDhvK9xuEFvYyyQA2SOFT_xfadc42Nsk,759
|
17
|
+
audiolibrarian/audiofile/formats/flac.py,sha256=UNXfZbnL0cjzmlS3nuZ26QczrfD-twgc7S6PbtoOkqA,9713
|
18
|
+
audiolibrarian/audiofile/formats/m4a.py,sha256=Ki-pHnObeEMXegIAHu7ymtB_PjMiWLDU5HHXaWxaPPo,10557
|
19
|
+
audiolibrarian/audiofile/formats/mp3.py,sha256=SQOcPVAE-coDz4tK-gopWSWJE02nQBTbxrlmRkyK7QU,12886
|
20
|
+
audiolibrarian/entrypoints/__init__.py,sha256=9xOQM6uwVi_sP5pdnKI_mVIIEWEGvp2MvMOHA28RmrQ,772
|
21
|
+
audiolibrarian/entrypoints/cli.py,sha256=Mz_MsjHK5NyWwfv1VBZrlgvzt4VR8M53cGzWbqZccP8,4223
|
22
|
+
audiolibrarian/templates/config.toml,sha256=NYf6kN0cEY-34_76v1r9nB6PChdm2Q8N2zhzlSDUmmI,803
|
23
|
+
picard_src/README.md,sha256=mtJ7RNLlC7Oz9M038WU-3ciPa7jPdXlFLYdJBL8iRQo,411
|
24
|
+
picard_src/__init__.py,sha256=acu0-oac_qfEgiB0rw0RvuL---O15-rOckWboVMxWtM,198
|
25
|
+
picard_src/textencoding.py,sha256=0MRHFwhqEwauQbjTTz6gpgzo6YH1VDPfdJqQoCWHtjM,26234
|
26
|
+
audiolibrarian-0.18.0.dist-info/METADATA,sha256=c0Jv3vtxtJdf8o-KsqjSmBdZSDRCQzHViqaELj1Nmhg,2578
|
27
|
+
audiolibrarian-0.18.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
28
|
+
audiolibrarian-0.18.0.dist-info/entry_points.txt,sha256=6TrDdRLEnIpD56rCeA0TURxwhT3vaFpeyis1zRF0Kdo,71
|
29
|
+
audiolibrarian-0.18.0.dist-info/licenses/COPYING,sha256=WJ7YI-moTFb-uVrFjnzzhGJrnL9P2iqQe8NuED3hutI,35141
|
30
|
+
audiolibrarian-0.18.0.dist-info/licenses/LICENSE,sha256=WJ7YI-moTFb-uVrFjnzzhGJrnL9P2iqQe8NuED3hutI,35141
|
31
|
+
audiolibrarian-0.18.0.dist-info/RECORD,,
|
@@ -1,28 +0,0 @@
|
|
1
|
-
audiolibrarian/__init__.py,sha256=nk7MHHw3booS4scifS56NhceSDNsLnU7vcljwNcLcZ0,792
|
2
|
-
audiolibrarian/audiosource.py,sha256=Xs8KBvb0Jd-JRHv3veyBA9lL2siC70-gO2RdZqy2ntk,8550
|
3
|
-
audiolibrarian/base.py,sha256=yDN2PELNboUr_eD-Ix8xi-JHhq8XbDuaj4AnvergSIE,21263
|
4
|
-
audiolibrarian/cli.py,sha256=vtAJND7U5yoBCbm20HRCvIFp2HYzXl3jfHNfsNUQVlE,4196
|
5
|
-
audiolibrarian/commands.py,sha256=rM44NQUKtZsBI3C8EY0u0vRrricfj3felM00x16fBeU,11171
|
6
|
-
audiolibrarian/genremanager.py,sha256=ag4LGcXPHjuI-Y07DTmGjVcaUKyKazyWHGBak9YzVAg,7362
|
7
|
-
audiolibrarian/musicbrainz.py,sha256=hoZKDueqgfMo-Da_ZH3_bQcRXqRvaz9KCx9MARMI5h8,19317
|
8
|
-
audiolibrarian/output.py,sha256=MQGsKrOfkRASGkVG4CEi2kgj0smEm8TfXqAR-lwULwk,1707
|
9
|
-
audiolibrarian/records.py,sha256=87DXbpwzl8Fsh71Nzi5XmJDAaEGvRjH6vwJ75cIM9PQ,7934
|
10
|
-
audiolibrarian/settings.py,sha256=GSuT3zOa6jyrejx14_dLwJaTA6eCTNYFq6EQ9UQOzhI,4042
|
11
|
-
audiolibrarian/sh.py,sha256=7V-FrSSiq56cNL1IyYBEcSUXtMbwFs6Ob1EhU_i_c84,1917
|
12
|
-
audiolibrarian/text.py,sha256=VrxrQm6pZtuYbK9MlukSC3MdqDBSlsnTgkhBTwj9MpY,4031
|
13
|
-
audiolibrarian/audiofile/__init__.py,sha256=PTBAVV5gfSc0UVvWMHyeL1d3xwhgJ8J00L-KVhuCGhY,898
|
14
|
-
audiolibrarian/audiofile/audiofile.py,sha256=UCVXu2ga7stnTzeeLzrx-JTFl5ad82qy45HmB3g2a8c,4357
|
15
|
-
audiolibrarian/audiofile/tags.py,sha256=Di8TjvOYZYYgedMNRZ8tTGFxZ1KN4oAK7z17TzTSeZg,1790
|
16
|
-
audiolibrarian/audiofile/formats/__init__.py,sha256=DexI0KdI6hnbDhvK9xuEFvYyyQA2SOFT_xfadc42Nsk,759
|
17
|
-
audiolibrarian/audiofile/formats/flac.py,sha256=UNXfZbnL0cjzmlS3nuZ26QczrfD-twgc7S6PbtoOkqA,9713
|
18
|
-
audiolibrarian/audiofile/formats/m4a.py,sha256=Ki-pHnObeEMXegIAHu7ymtB_PjMiWLDU5HHXaWxaPPo,10557
|
19
|
-
audiolibrarian/audiofile/formats/mp3.py,sha256=SQOcPVAE-coDz4tK-gopWSWJE02nQBTbxrlmRkyK7QU,12886
|
20
|
-
picard_src/README.md,sha256=mtJ7RNLlC7Oz9M038WU-3ciPa7jPdXlFLYdJBL8iRQo,411
|
21
|
-
picard_src/__init__.py,sha256=acu0-oac_qfEgiB0rw0RvuL---O15-rOckWboVMxWtM,198
|
22
|
-
picard_src/textencoding.py,sha256=0MRHFwhqEwauQbjTTz6gpgzo6YH1VDPfdJqQoCWHtjM,26234
|
23
|
-
audiolibrarian-0.17.0.dist-info/METADATA,sha256=CvHJpVXiHY01H6rQ828Nu_a4Yzr-KXrXWXh5BgOl-X0,2578
|
24
|
-
audiolibrarian-0.17.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
25
|
-
audiolibrarian-0.17.0.dist-info/entry_points.txt,sha256=reubnr_SGbTTDXji8j7z8aTmIL0AEQKVSLcnmFG3YYY,59
|
26
|
-
audiolibrarian-0.17.0.dist-info/licenses/COPYING,sha256=WJ7YI-moTFb-uVrFjnzzhGJrnL9P2iqQe8NuED3hutI,35141
|
27
|
-
audiolibrarian-0.17.0.dist-info/licenses/LICENSE,sha256=WJ7YI-moTFb-uVrFjnzzhGJrnL9P2iqQe8NuED3hutI,35141
|
28
|
-
audiolibrarian-0.17.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|