audiolibrarian 0.16.5__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.
@@ -1,7 +1,7 @@
1
1
  """The audiolibrarian package."""
2
2
 
3
3
  #
4
- # Copyright (c) 2020 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
@@ -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.16.5"
19
+ __version__ = "0.18.0"
@@ -1,7 +1,6 @@
1
1
  """Audio file library."""
2
-
3
2
  #
4
- # Copyright (c) 2020 Stephen Jibson
3
+ # Copyright (c) 2000-2025 Stephen Jibson
5
4
  #
6
5
  # This file is part of audiolibrarian.
7
6
  #
@@ -1,7 +1,7 @@
1
1
  """Audio file library."""
2
2
 
3
3
  #
4
- # Copyright (c) 2020 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
@@ -1 +1,17 @@
1
1
  """AudioFile formats."""
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
+ #
@@ -1,7 +1,7 @@
1
1
  """AudioFile support for flac files."""
2
2
 
3
3
  #
4
- # Copyright (c) 2020 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
@@ -1,7 +1,7 @@
1
1
  """AudioFile support for m4a files."""
2
2
 
3
3
  #
4
- # Copyright (c) 2020 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
@@ -1,7 +1,7 @@
1
1
  """AudioFile support for mp3 files."""
2
2
 
3
3
  #
4
- # Copyright (c) 2020 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
@@ -1,7 +1,7 @@
1
1
  """Manage tags."""
2
2
 
3
3
  #
4
- # Copyright (c) 2020 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
@@ -1,7 +1,7 @@
1
1
  """AudioSource."""
2
2
 
3
3
  #
4
- # Copyright (c) 2020 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
@@ -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
@@ -4,7 +4,7 @@ Useful stuff: https://help.mp3tag.de/main_tags.html
4
4
  """
5
5
 
6
6
  #
7
- # Copyright (c) 2020 Stephen Jibson
7
+ # Copyright (c) 2000-2025 Stephen Jibson
8
8
  #
9
9
  # This file is part of audiolibrarian.
10
10
  #
@@ -23,18 +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
- from typing import Any
29
+ from typing import Any, Final
31
30
 
32
31
  import colors
33
32
  import filelock
34
33
  import yaml
35
34
 
36
- from audiolibrarian import audiofile, audiosource, musicbrainz, records, sh, text
37
- 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
+ )
38
45
 
39
46
  log = logging.getLogger(__name__)
40
47
 
@@ -46,9 +53,11 @@ class Base:
46
53
  """
47
54
 
48
55
  command: str | None = None
56
+ _manifest_file: Final[str] = "Manifest.yaml"
49
57
 
50
- def __init__(self, args: argparse.Namespace) -> None:
58
+ def __init__(self, args: argparse.Namespace, settings: config.Settings) -> None:
51
59
  """Initialize the base."""
60
+ self._settings = settings
52
61
  # Pull in stuff from args.
53
62
  search_keys = ("album", "artist", "mb_artist_id", "mb_release_id")
54
63
  self._provided_search_data = {k: v for k, v in vars(args).items() if k in search_keys}
@@ -58,17 +67,18 @@ class Base:
58
67
  self._disc_number, self._disc_count = 1, 1
59
68
 
60
69
  # Directories.
61
- self._library_dir = SETTINGS.library_dir
62
- self._work_dir = SETTINGS.work_dir
70
+ self._library_dir = self._settings.library_dir
71
+ self._work_dir = self._settings.work_dir
63
72
  self._flac_dir = self._work_dir / "flac"
64
73
  self._m4a_dir = self._work_dir / "m4a"
65
74
  self._mp3_dir = self._work_dir / "mp3"
66
75
  self._source_dir = self._work_dir / "source"
67
76
  self._wav_dir = self._work_dir / "wav"
68
77
 
69
- self._manifest_file = "Manifest.yaml"
70
78
  self._lock = filelock.FileLock(str(self._work_dir) + ".lock")
71
79
 
80
+ self._normalizer = normalizer.Normalizer.factory(self._settings.normalize)
81
+
72
82
  # Initialize stuff that will be defined later.
73
83
  self._audio_source: audiosource.AudioSource | None = None
74
84
  self._release: records.Release | None = None
@@ -126,24 +136,6 @@ class Base:
126
136
  self._make_mp3()
127
137
  self._move_files(move_source=make_source)
128
138
 
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
139
  def _find_manifests(self, directories: list[str | pathlib.Path]) -> list[pathlib.Path]:
148
140
  """Return a sorted, unique list of manifest files anywhere in the given directories."""
149
141
  manifests = set()
@@ -158,7 +150,7 @@ class Base:
158
150
  search_data: dict[str, str] = (
159
151
  self._audio_source.get_search_data() if self._audio_source is not None else {}
160
152
  )
161
- searcher = musicbrainz.Searcher(**search_data) # type: ignore[arg-type]
153
+ searcher = musicbrainz.Searcher(settings=self._settings.musicbrainz, **search_data) # type: ignore[arg-type]
162
154
  searcher.disc_number = str(self._disc_number)
163
155
  # Override with user-provided info.
164
156
  if value := self._provided_search_data.get("artist"):
@@ -283,25 +275,8 @@ class Base:
283
275
  path.rename(source_dir / path.name)
284
276
 
285
277
  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))
278
+ """Normalize the wav files using the selected normalizer."""
279
+ self._normalizer.normalize(set(self._wav_filenames))
305
280
 
306
281
  def _rename_wav(self) -> None:
307
282
  """Rename the wav files to a filename-sane representation of the track title."""
@@ -431,3 +406,26 @@ class Base:
431
406
  with pathlib.Path(manifest_filename).open("w", encoding="utf-8") as manifest_file:
432
407
  yaml.dump(manifest, manifest_file)
433
408
  print(f"Wrote {manifest_filename}")
409
+
410
+ @staticmethod
411
+ def _find_audio_files(directories: list[str | pathlib.Path]) -> Iterable[audiofile.AudioFile]:
412
+ """Yield audiofile objects found in the given directories."""
413
+ paths: list[pathlib.Path] = []
414
+ # Grab all the paths first because thing may change as files are renamed.
415
+ for directory in directories:
416
+ path = pathlib.Path(directory)
417
+ for ext in audiofile.AudioFile.extensions():
418
+ paths.extend(path.rglob(f"*{ext}"))
419
+ paths = sorted(set(paths))
420
+ # Using yield rather than returning a list saves us from simultaneously storing
421
+ # potentially thousands of AudioFile objects in memory at the same time.
422
+ for path in paths:
423
+ try:
424
+ yield audiofile.AudioFile.open(path)
425
+ except FileNotFoundError:
426
+ continue
427
+
428
+ @staticmethod
429
+ def _read_manifest(manifest_path: pathlib.Path) -> dict[Any, Any]:
430
+ with manifest_path.open(encoding="utf-8") as manifest_file:
431
+ return dict(yaml.safe_load(manifest_file))
@@ -1,7 +1,7 @@
1
1
  """Command line commands."""
2
2
 
3
3
  #
4
- # Copyright (c) 2020 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
@@ -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}
@@ -0,0 +1,152 @@
1
+ """Configuration module for AudioLibrarian.
2
+
3
+ This module provides configuration management using pydantic-settings, supporting multiple
4
+ sources of configuration:
5
+
6
+ 1. Environment Variables (highest precedence):
7
+ - Prefix: "AUDIOLIBRARIAN__"
8
+ - Nested fields: Use "__" as delimiter (e.g., AUDIOLIBRARIAN__MUSICBRAINZ__USERNAME)
9
+
10
+ 2. TOML Configuration File:
11
+ - Location: $XDG_CONFIG_HOME/audiolibrarian/config.toml
12
+ - Supports nested structure for MusicBrainz settings
13
+
14
+ 3. Default Values:
15
+ - Defined in Settings class
16
+ - Lowest precedence
17
+
18
+ The configuration is immutable (frozen=True) and follows XDG base directory standards for
19
+ configuration and cache locations.
20
+
21
+ Sensitive fields (like passwords) are handled using pydantic.SecretStr for security.
22
+ """
23
+
24
+ #
25
+ # Copyright (c) 2000-2025 Stephen Jibson
26
+ #
27
+ # This file is part of audiolibrarian.
28
+ #
29
+ # Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
30
+ # GNU General Public License as published by the Free Software Foundation, either version 3 of the
31
+ # License, or (at your option) any later version.
32
+ #
33
+ # Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
34
+ # without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
35
+ # the GNU General Public License for more details.
36
+ #
37
+ # You should have received a copy of the GNU General Public License along with audiolibrarian.
38
+ # If not, see <https://www.gnu.org/licenses/>.
39
+ #
40
+ import logging
41
+ import pathlib
42
+ from typing import Annotated, Final, Literal
43
+
44
+ import pydantic
45
+ import pydantic_settings
46
+ import xdg_base_dirs
47
+
48
+ CONFIG_PATH: Final[pathlib.Path] = (
49
+ xdg_base_dirs.xdg_config_home() / "audiolibrarian" / "config.toml"
50
+ )
51
+
52
+ logger = logging.getLogger(__name__)
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."""
62
+
63
+
64
+ class MusicBrainzSettings(pydantic.BaseModel):
65
+ """Configuration settings for MusicBrainz."""
66
+
67
+ password: pydantic.SecretStr = pydantic.SecretStr("")
68
+ username: str = ""
69
+ rate_limit: pydantic.PositiveFloat = 1.5 # Seconds between requests.
70
+ work_dir: ExpandedPath = xdg_base_dirs.xdg_cache_home() / "audiolibrarian"
71
+
72
+
73
+ class NormalizeFFmpegSettings(pydantic.BaseModel):
74
+ """Configuration settings for ffmpeg normalization."""
75
+
76
+ target_level: float = -13
77
+
78
+
79
+ class NormalizeWavegainSettings(pydantic.BaseModel):
80
+ """Configuration settings for wavegain normalization."""
81
+
82
+ gain: int = 5 # dB
83
+ preset: Literal["album", "radio"] = "radio"
84
+
85
+
86
+ class NormalizeSettings(pydantic.BaseModel):
87
+ """Configuration settings for audio normalization."""
88
+
89
+ normalizer: Literal["auto", "wavegain", "ffmpeg", "none"] = "auto"
90
+ ffmpeg: NormalizeFFmpegSettings = NormalizeFFmpegSettings()
91
+ wavegain: NormalizeWavegainSettings = NormalizeWavegainSettings()
92
+
93
+
94
+ class Settings(pydantic_settings.BaseSettings):
95
+ """Configuration settings for AudioLibrarian."""
96
+
97
+ discid_device: str = "" # Use default device.
98
+ library_dir: ExpandedPath = pathlib.Path("library").resolve()
99
+ musicbrainz: MusicBrainzSettings = MusicBrainzSettings()
100
+ normalize: NormalizeSettings = NormalizeSettings()
101
+ work_dir: ExpandedPath = xdg_base_dirs.xdg_cache_home() / "audiolibrarian"
102
+
103
+ model_config = pydantic_settings.SettingsConfigDict(
104
+ env_nested_delimiter="__",
105
+ env_prefix="AUDIOLIBRARIAN__",
106
+ toml_file=str(CONFIG_PATH),
107
+ frozen=True, # Make settings immutable.
108
+ )
109
+
110
+ @classmethod
111
+ def settings_customise_sources(
112
+ cls,
113
+ settings_cls: type[pydantic_settings.BaseSettings],
114
+ init_settings: pydantic_settings.PydanticBaseSettingsSource,
115
+ env_settings: pydantic_settings.PydanticBaseSettingsSource,
116
+ dotenv_settings: pydantic_settings.PydanticBaseSettingsSource,
117
+ file_secret_settings: pydantic_settings.PydanticBaseSettingsSource,
118
+ ) -> tuple[pydantic_settings.PydanticBaseSettingsSource, ...]:
119
+ """Customize settings sources."""
120
+ del dotenv_settings, file_secret_settings # Unused.
121
+ return (
122
+ init_settings, # Used for tests.
123
+ env_settings,
124
+ pydantic_settings.TomlConfigSettingsSource(settings_cls),
125
+ )
126
+
127
+
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
+ #
@@ -1,7 +1,7 @@
1
1
  """Audiolibrarian command line interface."""
2
2
 
3
3
  #
4
- # Copyright (c) 2021 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
@@ -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
 
@@ -40,7 +40,6 @@ class CommandLineInterface:
40
40
  "lame",
41
41
  "mpg123",
42
42
  "sndfile-convert",
43
- "wavegain",
44
43
  }
45
44
 
46
45
  def __init__(self, *, parse_args: bool = True) -> None:
@@ -58,7 +57,7 @@ class CommandLineInterface:
58
57
  if self._args.command == cmd.command:
59
58
  if not cmd.validate_args(self._args):
60
59
  sys.exit(2)
61
- cmd(self._args)
60
+ cmd(self._args, config.Settings())
62
61
  break
63
62
  if self._args.log_level == logging.DEBUG:
64
63
  print(pathlib.Path("/proc/self/status").read_text(encoding="utf-8"))
@@ -1,7 +1,7 @@
1
1
  """Genre Manager."""
2
2
 
3
3
  #
4
- # Copyright (c) 2020 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
@@ -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
@@ -1,7 +1,7 @@
1
1
  """Access the MusicBrainz service."""
2
2
 
3
3
  #
4
- # Copyright (c) 2020 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
@@ -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")
audiolibrarian/output.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Screen output utilities."""
2
2
 
3
3
  #
4
- # Copyright (c) 2020 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
audiolibrarian/records.py CHANGED
@@ -4,7 +4,7 @@ Useful field reference: https://github.com/metabrainz/picard/blob/master/picard/
4
4
  """
5
5
 
6
6
  #
7
- # Copyright (c) 2020 Stephen Jibson
7
+ # Copyright (c) 2000-2025 Stephen Jibson
8
8
  #
9
9
  # This file is part of audiolibrarian.
10
10
  #
audiolibrarian/sh.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Command execution helpers."""
2
2
 
3
3
  #
4
- # Copyright (c) 2020 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
@@ -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
audiolibrarian/text.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Text utilities."""
2
2
 
3
3
  #
4
- # Copyright (c) 2020 Stephen Jibson
4
+ # Copyright (c) 2000-2025 Stephen Jibson
5
5
  #
6
6
  # This file is part of audiolibrarian.
7
7
  #
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: audiolibrarian
3
- Version: 0.16.5
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>
@@ -12,6 +12,7 @@ Classifier: Programming Language :: Python
12
12
  Requires-Python: <3.14,>=3.12
13
13
  Requires-Dist: ansicolors
14
14
  Requires-Dist: discid
15
+ Requires-Dist: ffmpeg-normalize
15
16
  Requires-Dist: filelock
16
17
  Requires-Dist: fuzzywuzzy
17
18
  Requires-Dist: musicbrainzngs
@@ -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,79 +0,0 @@
1
- """Configuration module for AudioLibrarian.
2
-
3
- This module provides configuration management using pydantic-settings, supporting multiple
4
- sources of configuration:
5
-
6
- 1. Environment Variables (highest precedence):
7
- - Prefix: "AUDIOLIBRARIAN__"
8
- - Nested fields: Use "__" as delimiter (e.g., AUDIOLIBRARIAN__MUSICBRAINZ__USERNAME)
9
-
10
- 2. YAML Configuration File:
11
- - Location: $XDG_CONFIG_HOME/audiolibrarian/config.yaml
12
- - Supports nested structure for MusicBrainz settings
13
-
14
- 3. Default Values:
15
- - Defined in Settings class
16
- - Lowest precedence
17
-
18
- The configuration is immutable (frozen=True) and follows XDG base directory standards for
19
- configuration and cache locations.
20
-
21
- Sensitive fields (like passwords) are handled using pydantic.SecretStr for security.
22
- """
23
-
24
- import logging
25
- import pathlib
26
- from typing import Literal
27
-
28
- import pydantic
29
- import xdg_base_dirs
30
- from pydantic_settings import (
31
- BaseSettings,
32
- PydanticBaseSettingsSource,
33
- SettingsConfigDict,
34
- YamlConfigSettingsSource,
35
- )
36
-
37
- logger = logging.getLogger(__name__)
38
-
39
-
40
- class MusicBrainzSettings(BaseSettings):
41
- """Configuration settings for MusicBrainz."""
42
-
43
- password: pydantic.SecretStr = pydantic.SecretStr("")
44
- username: str = ""
45
- rate_limit: pydantic.PositiveFloat = 1.5 # Seconds between requests.
46
-
47
-
48
- class Settings(BaseSettings):
49
- """Configuration settings for AudioLibrarian."""
50
-
51
- discid_device: str | None = None # Use default device.
52
- library_dir: pathlib.Path = pathlib.Path("library").resolve()
53
- musicbrainz: MusicBrainzSettings = MusicBrainzSettings()
54
- normalize_gain: int = 5 # dB
55
- normalize_preset: Literal["album", "radio"] = "radio"
56
- work_dir: pathlib.Path = xdg_base_dirs.xdg_cache_home() / "audiolibrarian"
57
-
58
- model_config = SettingsConfigDict(
59
- env_nested_delimiter="__",
60
- env_prefix="AUDIOLIBRARIAN__",
61
- yaml_file=str(xdg_base_dirs.xdg_config_home() / "audiolibrarian" / "config.yaml"),
62
- frozen=True, # Make settings immutable.
63
- )
64
-
65
- @classmethod
66
- def settings_customise_sources(
67
- cls,
68
- settings_cls: type[BaseSettings],
69
- init_settings: PydanticBaseSettingsSource,
70
- env_settings: PydanticBaseSettingsSource,
71
- dotenv_settings: PydanticBaseSettingsSource,
72
- file_secret_settings: PydanticBaseSettingsSource,
73
- ) -> tuple[PydanticBaseSettingsSource, ...]:
74
- del dotenv_settings, file_secret_settings # Unused.
75
- return env_settings, YamlConfigSettingsSource(settings_cls), init_settings
76
-
77
-
78
- SETTINGS = Settings()
79
- __all__ = ["SETTINGS"]
@@ -1,28 +0,0 @@
1
- audiolibrarian/__init__.py,sha256=gY9TxON-FJ8rKpy6s2RKFMpH0qMDrgDtE9Nn6q8xIjM,787
2
- audiolibrarian/audiosource.py,sha256=hQVFc2VQfCPV1kQRGOwwNAuMx4AXno0zcf_0Ps_Y2ts,8545
3
- audiolibrarian/base.py,sha256=54s_NacDvyGpvc_uOJO5apmQin4HKUGfPrlw0XFiJoI,19428
4
- audiolibrarian/cli.py,sha256=azX49RO2t4q8qyUJ9WTOlDqUO13p17mq0wNmEdl6QGw,4211
5
- audiolibrarian/commands.py,sha256=4oyXDSvzlL2vb1SyD2GVy8bDD9MTJz9IuTMl2PMLOQ4,11166
6
- audiolibrarian/genremanager.py,sha256=omjsn-8Bdz3etLu-qNAXhPUOyY4Byy6muwvkHyPM6so,7357
7
- audiolibrarian/musicbrainz.py,sha256=GwTrwvn-hLZw4uLD7CshyegLCM1tfeNL00rqz2Xsl20,19312
8
- audiolibrarian/output.py,sha256=JHuttwEMEzUxty7P8e2jaL6702q52fTWAdZEgb85bxQ,1702
9
- audiolibrarian/records.py,sha256=BovHwNVd0nTTwQPYkhOAOIVASWz9l47j6cGdXAx5VjM,7929
10
- audiolibrarian/settings.py,sha256=qKQ9JYfKr2k_JceWfYPiMlpBKylujCQI_2pmxWpsGFo,2550
11
- audiolibrarian/sh.py,sha256=tVTuKXlMH738jTopjnMWqidTaH02_EyHau3q9kQ6mYE,1912
12
- audiolibrarian/text.py,sha256=05q_E22w9hMsy01_A5XlU2pqQIMlM5RF4DXzYuLCcYo,4026
13
- audiolibrarian/audiofile/__init__.py,sha256=BRjf4iu_cogB3A5oIVE4BjIgUGCH0dAzPG4A_48rOUM,894
14
- audiolibrarian/audiofile/audiofile.py,sha256=IhTIzFzYeKfJmsgOG7bN62KWD06ehOGRoVv3eCf-_vw,4352
15
- audiolibrarian/audiofile/tags.py,sha256=42oyqHZ6jRokmQskD2p53NMgKxJR7xkidXImOhUzszY,1785
16
- audiolibrarian/audiofile/formats/__init__.py,sha256=ZV5urTtQQ4ZX_K2s9qFsSzDunvdQaQ43K7CE-BPPtEo,25
17
- audiolibrarian/audiofile/formats/flac.py,sha256=3MfzdgnAqTVSbAraAVde2BSHwdAyi-fKkc55OvuRNSc,9708
18
- audiolibrarian/audiofile/formats/m4a.py,sha256=S0aFZxMzgwpzkwYE2D_K_US0Dnh0objK21ph1S_8rJk,10552
19
- audiolibrarian/audiofile/formats/mp3.py,sha256=ipuanK7CIYSk9LqW0AvAgKAUZeU9ecwK0uZf2AhAGXo,12881
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.16.5.dist-info/METADATA,sha256=m92zy8tCb5anGh-_ltl2Hca0jkB8QRW6locAtVnuskQ,2546
24
- audiolibrarian-0.16.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
- audiolibrarian-0.16.5.dist-info/entry_points.txt,sha256=reubnr_SGbTTDXji8j7z8aTmIL0AEQKVSLcnmFG3YYY,59
26
- audiolibrarian-0.16.5.dist-info/licenses/COPYING,sha256=WJ7YI-moTFb-uVrFjnzzhGJrnL9P2iqQe8NuED3hutI,35141
27
- audiolibrarian-0.16.5.dist-info/licenses/LICENSE,sha256=WJ7YI-moTFb-uVrFjnzzhGJrnL9P2iqQe8NuED3hutI,35141
28
- audiolibrarian-0.16.5.dist-info/RECORD,,
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- audiolibrarian = audiolibrarian:cli.main