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.
@@ -16,4 +16,4 @@
16
16
  # You should have received a copy of the GNU General Public License along with audiolibrarian.
17
17
  # If not, see <https://www.gnu.org/licenses/>.
18
18
  #
19
- __version__ = "0.17.0"
19
+ __version__ = "0.18.0"
@@ -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(SETTINGS.discid_device, features=["mcn"])
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 audiofile, audiosource, musicbrainz, records, sh, text
38
- from audiolibrarian.settings import SETTINGS
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 = SETTINGS.library_dir
64
- self._work_dir = SETTINGS.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._which_normalizer()
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
- if self._normalizer == "none":
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."""
@@ -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. YAML Configuration File:
11
- - Location: $XDG_CONFIG_HOME/audiolibrarian/config.yaml
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
- class MusicBrainzSettings(pydantic_settings.BaseSettings):
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(pydantic_settings.BaseSettings):
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(pydantic_settings.BaseSettings):
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(pydantic_settings.BaseSettings):
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 | None = None # Use default device.
84
- library_dir: pathlib.Path = pathlib.Path("library").resolve()
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: pathlib.Path = xdg_base_dirs.xdg_cache_home() / "audiolibrarian"
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
- yaml_file=str(xdg_base_dirs.xdg_config_home() / "audiolibrarian" / "config.yaml"),
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.YamlConfigSettingsSource(settings_cls),
109
- init_settings,
124
+ pydantic_settings.TomlConfigSettingsSource(settings_cls),
110
125
  )
111
126
 
112
127
 
113
- SETTINGS = Settings()
114
- __all__ = ["SETTINGS"]
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"))
@@ -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._mb = musicbrainz.MusicBrainzSession()
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 = SETTINGS.work_dir / "user-genres.pickle"
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
@@ -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 := SETTINGS.musicbrainz.username) and (
69
- password := SETTINGS.musicbrainz.password.get_secret_value()
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
- @staticmethod
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 (sleep_seconds := (MusicBrainzSession._api_rate - since_last).total_seconds()) > 0:
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__(self, release_id: str, *, verbose: bool = False) -> None:
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: audiolibrarian
3
- Version: 0.17.0
3
+ Version: 0.18.0
4
4
  Summary: Manage my audio library.
5
5
  Project-URL: Repository, https://github.com/toadstule/audiolibrarian
6
6
  Author-email: Steve Jibson <steve@jibson.com>
@@ -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,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ audiolibrarian = audiolibrarian.entrypoints.cli:main
@@ -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,,
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- audiolibrarian = audiolibrarian:cli.main