audiolibrarian 0.16.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- audiolibrarian/__init__.py +19 -0
- audiolibrarian/audiofile/__init__.py +23 -0
- audiolibrarian/audiofile/audiofile.py +114 -0
- audiolibrarian/audiofile/formats/__init__.py +1 -0
- audiolibrarian/audiofile/formats/flac.py +207 -0
- audiolibrarian/audiofile/formats/m4a.py +221 -0
- audiolibrarian/audiofile/formats/mp3.py +259 -0
- audiolibrarian/audiofile/tags.py +48 -0
- audiolibrarian/audiosource.py +215 -0
- audiolibrarian/base.py +433 -0
- audiolibrarian/cli.py +123 -0
- audiolibrarian/commands.py +283 -0
- audiolibrarian/genremanager.py +176 -0
- audiolibrarian/musicbrainz.py +465 -0
- audiolibrarian/output.py +57 -0
- audiolibrarian/records.py +259 -0
- audiolibrarian/settings.py +79 -0
- audiolibrarian/sh.py +55 -0
- audiolibrarian/text.py +115 -0
- audiolibrarian-0.16.2.dist-info/METADATA +334 -0
- audiolibrarian-0.16.2.dist-info/RECORD +28 -0
- audiolibrarian-0.16.2.dist-info/WHEEL +4 -0
- audiolibrarian-0.16.2.dist-info/entry_points.txt +2 -0
- audiolibrarian-0.16.2.dist-info/licenses/COPYING +674 -0
- audiolibrarian-0.16.2.dist-info/licenses/LICENSE +674 -0
- picard_src/README.md +11 -0
- picard_src/__init__.py +7 -0
- picard_src/textencoding.py +495 -0
@@ -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]
|