audiolibrarian 0.16.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,259 @@
1
+ """Record (dataclass) definitions.
2
+
3
+ Useful field reference: https://github.com/metabrainz/picard/blob/master/picard/util/tags.py
4
+ """
5
+
6
+ #
7
+ # Copyright (c) 2020 Stephen Jibson
8
+ #
9
+ # This file is part of audiolibrarian.
10
+ #
11
+ # Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
12
+ # GNU General Public License as published by the Free Software Foundation, either version 3 of the
13
+ # License, or (at your option) any later version.
14
+ #
15
+ # Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
16
+ # without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
17
+ # the GNU General Public License for more details.
18
+ #
19
+ # You should have received a copy of the GNU General Public License along with audiolibrarian.
20
+ # If not, see <https://www.gnu.org/licenses/>.
21
+ #
22
+ import dataclasses
23
+ import enum
24
+ import pathlib
25
+ from typing import Any
26
+
27
+ from audiolibrarian import text
28
+
29
+
30
+ class BitrateMode(enum.Enum):
31
+ """Bitrate modes."""
32
+
33
+ UNKNOWN = 0
34
+ CBR = 1
35
+ VBR = 2
36
+
37
+
38
+ class FileType(enum.Enum):
39
+ """Audio file types."""
40
+
41
+ UNKNOWN = 0
42
+ AAC = 1
43
+ FLAC = 2
44
+ MP3 = 3
45
+ WAV = 4
46
+
47
+
48
+ class Source(enum.Enum):
49
+ """Information source."""
50
+
51
+ MUSICBRAINZ = 1
52
+ TAGS = 2
53
+
54
+
55
+ class ListF(list[Any]):
56
+ """A list, with a first property."""
57
+
58
+ @property
59
+ def first(self) -> Any | None: # noqa: ANN401
60
+ """Return the first element in the list (or None, if the list is empty)."""
61
+ return self[0] if self else None
62
+
63
+
64
+ @dataclasses.dataclass(kw_only=True)
65
+ class Record:
66
+ """Base class for records.
67
+
68
+ Overrides true/false test for records returning false if all fields are None.
69
+ """
70
+
71
+ def __bool__(self) -> bool:
72
+ """Return a boolean representation of the Record."""
73
+ return bool([x for x in dataclasses.asdict(self).values() if x is not None])
74
+
75
+ def asdict(self) -> dict[Any, Any]:
76
+ """Return a dict version of the record."""
77
+ return dataclasses.asdict(self)
78
+
79
+
80
+ # Primitive Record Types
81
+ @dataclasses.dataclass(kw_only=True)
82
+ class FileInfo(Record):
83
+ """File information."""
84
+
85
+ bitrate: int | None = None
86
+ bitrate_mode: BitrateMode | None = None
87
+ path: pathlib.Path | None = None
88
+ type: FileType | None = None
89
+
90
+
91
+ @dataclasses.dataclass(kw_only=True)
92
+ class FrontCover(Record):
93
+ """A front cover."""
94
+
95
+ data: bytes | None = None
96
+ desc: str | None = None
97
+ mime: str | None = None
98
+
99
+
100
+ @dataclasses.dataclass(kw_only=True)
101
+ class Performer(Record):
102
+ """A performer with an instrument."""
103
+
104
+ name: str | None = None
105
+ instrument: str | None = None
106
+
107
+
108
+ @dataclasses.dataclass(kw_only=True)
109
+ class Track(Record):
110
+ """A track."""
111
+
112
+ artist: str | None = None
113
+ artists: ListF | None = None
114
+ artists_sort: list[str] | None = None
115
+ file_info: FileInfo | None = None
116
+ isrcs: list[str] | None = None
117
+ musicbrainz_artist_ids: ListF | None = None
118
+ musicbrainz_release_track_id: str | None = None
119
+ musicbrainz_track_id: str | None = None
120
+ title: str | None = None
121
+ track_number: int | None = None
122
+
123
+ def get_filename(self, suffix: str = "") -> str:
124
+ """Return a sane filename based on track number and title.
125
+
126
+ If suffix is included, it will be appended to the filename.
127
+ """
128
+ if self.title is None or self.track_number is None:
129
+ msg = "Unable to generate a filename for Track with missing number and/or title"
130
+ raise ValueError(msg)
131
+ return (
132
+ str(self.track_number).zfill(2) + "__" + text.filename_from_title(self.title) + suffix
133
+ )
134
+
135
+
136
+ # Combined Record Types (fields + other record types)
137
+ @dataclasses.dataclass(kw_only=True)
138
+ class Medium(Record):
139
+ """A medium."""
140
+
141
+ formats: ListF | None = None
142
+ titles: list[str] | None = None
143
+ track_count: int | None = None
144
+ tracks: dict[int, Track] | None = None
145
+
146
+
147
+ @dataclasses.dataclass(kw_only=True)
148
+ class People(Record):
149
+ """People."""
150
+
151
+ arrangers: list[str] | None = None
152
+ composers: list[str] | None = None
153
+ conductors: list[str] | None = None
154
+ engineers: list[str] | None = None
155
+ lyricists: list[str] | None = None
156
+ mixers: list[str] | None = None
157
+ performers: list[Performer] | None = None
158
+ producers: list[str] | None = None
159
+ writers: list[str] | None = None
160
+
161
+
162
+ @dataclasses.dataclass(kw_only=True)
163
+ class Release(Record):
164
+ """A release."""
165
+
166
+ album: str | None = None
167
+ album_artists: ListF | None = None
168
+ album_artists_sort: ListF | None = None
169
+ asins: list[str] | None = None
170
+ barcodes: list[str] | None = None
171
+ catalog_numbers: list[str] | None = None
172
+ date: str | None = None
173
+ front_cover: FrontCover | None = dataclasses.field(default=None, repr=False)
174
+ genres: ListF | None = None
175
+ labels: list[str] | None = None
176
+ media: dict[int, Medium] | None = None
177
+ medium_count: int | None = None
178
+ musicbrainz_album_artist_ids: ListF | None = None
179
+ musicbrainz_album_id: str | None = None
180
+ musicbrainz_release_group_id: str | None = None
181
+ original_date: str | None = None
182
+ original_year: str | None = None
183
+ people: People | None = None
184
+ release_countries: list[str] | None = None
185
+ release_statuses: list[str] | None = None
186
+ release_types: list[str] | None = None
187
+ script: str | None = None
188
+ source: Source | None = None
189
+
190
+ def get_artist_album_path(self) -> pathlib.Path:
191
+ """Return a directory for the artist/album/disc combination.
192
+
193
+ Example:
194
+ - artist__the/1969__the_album
195
+ """
196
+ if self.album_artists_sort is None or self.album_artists_sort.first is None:
197
+ msg = "Unable to determine artist path without artist(s)"
198
+ raise ValueError(msg)
199
+ artist_dir = pathlib.Path(text.filename_from_title(self.album_artists_sort.first))
200
+ if self.original_year is None or self.album is None:
201
+ msg = "Unable to determine album path without year and album"
202
+ raise ValueError(msg)
203
+ album_dir = pathlib.Path(text.filename_from_title(f"{self.original_year}__{self.album}"))
204
+ return artist_dir / album_dir
205
+
206
+ def pp(self, medium_number: int) -> str:
207
+ """Return a string summary of the Release."""
208
+ if self.media is None:
209
+ msg = "Missing release information"
210
+ raise ValueError(msg)
211
+ tracks = "\n".join(
212
+ (
213
+ f" {str(n).zfill(2)}: {t.title}"
214
+ for n, t in sorted(self.media[medium_number].tracks.items())
215
+ )
216
+ )
217
+ return "\n".join(
218
+ (
219
+ f"Album: {self.album}",
220
+ f"Artist(s): {', '.join(self.album_artists)}",
221
+ f"Medium: {medium_number} of {self.medium_count}",
222
+ "Tracks:",
223
+ tracks,
224
+ )
225
+ )
226
+
227
+
228
+ @dataclasses.dataclass(kw_only=True)
229
+ class OneTrack(Record):
230
+ """A single track."""
231
+
232
+ release: Release | None = None
233
+ medium_number: int | None = None
234
+ track_number: int | None = None
235
+
236
+ @property
237
+ def medium(self) -> Medium | None:
238
+ """Return the Medium object (or None)."""
239
+ if self.release and self.release.media:
240
+ return self.release.media[self.medium_number]
241
+ return None
242
+
243
+ @property
244
+ def track(self) -> Track | None:
245
+ """Return the Track object (or None)."""
246
+ if self.medium and self.medium.tracks:
247
+ return self.medium.tracks[self.track_number]
248
+ return None
249
+
250
+ def get_artist_album_disc_path(self) -> pathlib.Path:
251
+ """Return a directory for the artist/album/disc combination.
252
+
253
+ Example:
254
+ - artist__the/1969__the_album
255
+ - artist__the/1969__the_album/disc2
256
+ """
257
+ if (self.medium_number, self.release.medium_count) == (1, 1):
258
+ return self.release.get_artist_album_path()
259
+ return self.release.get_artist_album_path() / f"disc{self.medium_number}"
@@ -0,0 +1,79 @@
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"]
audiolibrarian/sh.py ADDED
@@ -0,0 +1,55 @@
1
+ """Command execution helpers."""
2
+
3
+ #
4
+ # Copyright (c) 2020 Stephen Jibson
5
+ #
6
+ # This file is part of audiolibrarian.
7
+ #
8
+ # Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
9
+ # GNU General Public License as published by the Free Software Foundation, either version 3 of the
10
+ # License, or (at your option) any later version.
11
+ #
12
+ # Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
13
+ # without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
14
+ # the GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License along with audiolibrarian.
17
+ # If not, see <https://www.gnu.org/licenses/>.
18
+ #
19
+ import pathlib
20
+ import subprocess
21
+ from collections.abc import Iterable
22
+ from multiprocessing import Pool
23
+
24
+ from audiolibrarian import output
25
+
26
+
27
+ def _run_command(command: tuple[str, ...]) -> None:
28
+ """Run a single command."""
29
+ subprocess.run(command, check=True) # noqa: S603
30
+
31
+
32
+ def parallel(
33
+ message: str, commands: list[tuple[str, ...]], max_workers: int | None = None
34
+ ) -> None:
35
+ """Execute commands in parallel using multiprocessing.
36
+
37
+ Args:
38
+ message: Progress message to display
39
+ commands: List of commands to execute
40
+ max_workers: Maximum number of parallel processes (None for system default)
41
+ """
42
+ with output.Dots(message) as dots, Pool(max_workers) as pool:
43
+ # Start all processes
44
+ results = [pool.apply_async(_run_command, (command,)) for command in commands]
45
+
46
+ # Wait for all processes to complete
47
+ for result in results:
48
+ result.get() # Will raise any exceptions from the subprocess
49
+ dots.dot()
50
+
51
+
52
+ def touch(paths: Iterable[pathlib.Path]) -> None:
53
+ """Touch all files in a given path."""
54
+ for path in paths:
55
+ path.touch(exist_ok=True)
audiolibrarian/text.py ADDED
@@ -0,0 +1,115 @@
1
+ """Text utilities."""
2
+
3
+ #
4
+ # Copyright (c) 2020 Stephen Jibson
5
+ #
6
+ # This file is part of audiolibrarian.
7
+ #
8
+ # Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
9
+ # GNU General Public License as published by the Free Software Foundation, either version 3 of the
10
+ # License, or (at your option) any later version.
11
+ #
12
+ # Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
13
+ # without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
14
+ # the GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License along with audiolibrarian.
17
+ # If not, see <https://www.gnu.org/licenses/>.
18
+ #
19
+ import pathlib
20
+ import re
21
+ import sys
22
+ from typing import Any
23
+
24
+ import picard_src
25
+
26
+ _DIGIT_REGEX = re.compile(r"([0-9]+)")
27
+ _UUID_REGEX = re.compile(
28
+ r"[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}", re.IGNORECASE
29
+ )
30
+
31
+
32
+ def alpha_numeric_key(text: str | pathlib.Path) -> list[Any]:
33
+ """Return a key that can be used for sorting alphanumeric strings numerically.
34
+
35
+ Example:
36
+ from audiolibrarian import text
37
+
38
+ l = ["8__eight", "7__seven", "10__ten", "11__eleven"]
39
+ sorted(l)
40
+ # ['10__ten', '11__eleven', '7__seven', '8__eight']
41
+ sorted(l, key=text.alpha_numeric_key)
42
+ # ['7__seven', '8__eight', '10__ten', '11__eleven']
43
+ """
44
+ return [int(x) if x.isdigit() else x for x in _DIGIT_REGEX.split(str(text))]
45
+
46
+
47
+ def filename_from_title(title: str) -> str:
48
+ """Convert a title into a filename."""
49
+ escape_required = "'!\"#$&'()*;<>?[]\\`{}|~\t\n); "
50
+ invalid = "/"
51
+ no_underscore_replace = "'!\""
52
+ results: list[str] = []
53
+ for char in picard_src.replace_non_ascii(title): # type: ignore[no-untyped-call]
54
+ if char == "&":
55
+ results.extend("and")
56
+ elif char.isascii() and char not in escape_required and char not in invalid:
57
+ results.append(char)
58
+ elif char not in no_underscore_replace:
59
+ results.append("_")
60
+ result = "".join(results).rstrip("_")
61
+ # Strip tailing dots, unless we end with an upper-case letter, then put one dot back.
62
+ if result.endswith(".") and result.rstrip(".")[-1].isupper():
63
+ return result.rstrip(".") + "."
64
+ return result.rstrip(".")
65
+
66
+
67
+ def fix(text: str) -> str:
68
+ """Replace some special characters."""
69
+ text = picard_src.unicode_simplify_combinations(text) # type: ignore[no-untyped-call]
70
+ text = picard_src.unicode_simplify_punctuation(text) # type: ignore[no-untyped-call]
71
+ return picard_src.unicode_simplify_compatibility(text) # type: ignore[no-untyped-call, no-any-return]
72
+
73
+
74
+ def get_numbers(text: str) -> list[int]:
75
+ """Get a list of all the numbers in the given string."""
76
+ return [int(x) for x in _DIGIT_REGEX.findall(text)]
77
+
78
+
79
+ def get_track_number(filename: str) -> int:
80
+ """Get a track number from a filename or from the user."""
81
+ if numbers := get_numbers(filename):
82
+ return numbers[0]
83
+ while True: # pragma: no cover
84
+ try:
85
+ return int(input_(f"Enter the track number for: {filename}"))
86
+ except ValueError:
87
+ pass
88
+
89
+
90
+ def get_uuid(text: str) -> str | None:
91
+ """Return the first UUID found within a given string."""
92
+ if (match := _UUID_REGEX.search(text)) is not None:
93
+ return match.group()
94
+ return None
95
+
96
+
97
+ def input_(prompt: str) -> str: # pragma: no cover
98
+ """Sound a terminal bell then prompt the user for input."""
99
+ sys.stdout.write("\a") # Terminal bell escape char.
100
+ sys.stdout.flush()
101
+ return input(prompt)
102
+
103
+
104
+ def join(strings: list[str], joiner: str = ", ", word: str = "and") -> str:
105
+ """Join string with joiner and word.
106
+
107
+ Example:
108
+ text.join(["eggs", "bacon", "spam"])
109
+ # "eggs, bacon and spam"
110
+ """
111
+ if not strings:
112
+ return ""
113
+ if len(strings) == 1:
114
+ return strings[0]
115
+ return joiner.join(strings[:-1]) + " " + word + " " + strings[-1]