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,465 @@
|
|
1
|
+
"""Access the MusicBrainz service."""
|
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 dataclasses
|
20
|
+
import datetime as dt
|
21
|
+
import http.client
|
22
|
+
import logging
|
23
|
+
import pprint
|
24
|
+
import time
|
25
|
+
import webbrowser
|
26
|
+
from typing import TYPE_CHECKING, Any, Final
|
27
|
+
|
28
|
+
import musicbrainzngs as mb
|
29
|
+
import requests
|
30
|
+
from fuzzywuzzy import fuzz
|
31
|
+
from requests import auth
|
32
|
+
|
33
|
+
from audiolibrarian import __version__, records, text
|
34
|
+
from audiolibrarian.settings import SETTINGS
|
35
|
+
|
36
|
+
if TYPE_CHECKING:
|
37
|
+
from collections.abc import Callable
|
38
|
+
|
39
|
+
log = logging.getLogger(__name__)
|
40
|
+
_USER_AGENT_NAME = "audiolibrarian"
|
41
|
+
_USER_AGENT_CONTACT = "audiolibrarian@jibson.com"
|
42
|
+
mb.set_useragent(_USER_AGENT_NAME, __version__, _USER_AGENT_CONTACT)
|
43
|
+
|
44
|
+
|
45
|
+
class MusicBrainzSession:
|
46
|
+
"""MusicBrainzSession provides access to the MusicBrainz API.
|
47
|
+
|
48
|
+
It can be for things that are not supported by the musicbrainzngs library.
|
49
|
+
"""
|
50
|
+
|
51
|
+
_api_rate = dt.timedelta(seconds=SETTINGS.musicbrainz.rate_limit)
|
52
|
+
_last_api_call = dt.datetime.now(tz=dt.UTC)
|
53
|
+
|
54
|
+
def __init__(self) -> None:
|
55
|
+
"""Initialize a MusicBrainzSession."""
|
56
|
+
self.__session: requests.Session | None = None
|
57
|
+
|
58
|
+
def __del__(self) -> None:
|
59
|
+
"""Close a MusicBrainzSession."""
|
60
|
+
if self.__session is not None:
|
61
|
+
self.__session.close()
|
62
|
+
|
63
|
+
@property
|
64
|
+
def _session(self) -> requests.Session:
|
65
|
+
if self.__session is None:
|
66
|
+
self.__session = requests.Session()
|
67
|
+
|
68
|
+
if (username := SETTINGS.musicbrainz.username) and (
|
69
|
+
password := SETTINGS.musicbrainz.password.get_secret_value()
|
70
|
+
):
|
71
|
+
self._session.auth = auth.HTTPDigestAuth(username, password)
|
72
|
+
self._session.headers.update(
|
73
|
+
{"User-Agent": f"{_USER_AGENT_NAME}/{__version__} ({_USER_AGENT_CONTACT})"}
|
74
|
+
)
|
75
|
+
return self.__session
|
76
|
+
|
77
|
+
def _get(self, path: str, params: dict[str, str]) -> dict[Any, Any]:
|
78
|
+
# Used for direct API calls; those not supported by the python library.
|
79
|
+
path = path.lstrip("/")
|
80
|
+
url = f"https://musicbrainz.org/ws/2/{path}"
|
81
|
+
params["fmt"] = "json"
|
82
|
+
self.sleep()
|
83
|
+
result = self._session.get(url, params=params)
|
84
|
+
while result.status_code == http.HTTPStatus.SERVICE_UNAVAILABLE:
|
85
|
+
log.warning("Waiting due to throttling...")
|
86
|
+
time.sleep(10)
|
87
|
+
result = self._session.get(url, params=params)
|
88
|
+
if result.status_code != http.HTTPStatus.OK:
|
89
|
+
msg = f"{result.status_code} - {url}"
|
90
|
+
raise RuntimeError(msg)
|
91
|
+
return dict(result.json())
|
92
|
+
|
93
|
+
def get_artist_by_id(
|
94
|
+
self, artist_id: str, includes: list[str] | None = None
|
95
|
+
) -> dict[str, Any]:
|
96
|
+
"""Return artist for the given musicbrainz-artist ID."""
|
97
|
+
params = {}
|
98
|
+
if includes is not None:
|
99
|
+
params["inc"] = "+".join(includes)
|
100
|
+
return self._get(f"artist/{artist_id}", params=params)
|
101
|
+
|
102
|
+
def get_release_group_by_id(
|
103
|
+
self, release_group_id: str, includes: list[str] | None = None
|
104
|
+
) -> dict[str, Any]:
|
105
|
+
"""Return release-group for the given musicbrainz-release-group ID."""
|
106
|
+
params = {}
|
107
|
+
if includes is not None:
|
108
|
+
params["inc"] = "+".join(includes)
|
109
|
+
return self._get(f"release-group/{release_group_id}", params=params)
|
110
|
+
|
111
|
+
@staticmethod
|
112
|
+
def sleep() -> None:
|
113
|
+
"""Sleep so we don't abuse the MusicBrainz API service.
|
114
|
+
|
115
|
+
See https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting
|
116
|
+
"""
|
117
|
+
since_last = dt.datetime.now(tz=dt.UTC) - MusicBrainzSession._last_api_call
|
118
|
+
if (sleep_seconds := (MusicBrainzSession._api_rate - since_last).total_seconds()) > 0:
|
119
|
+
log.debug("Sleeping %s to avoid throttling...", sleep_seconds)
|
120
|
+
time.sleep(sleep_seconds)
|
121
|
+
MusicBrainzSession._last_api_call = dt.datetime.now(tz=dt.UTC)
|
122
|
+
|
123
|
+
|
124
|
+
class MusicBrainzRelease:
|
125
|
+
"""MusicBrainzRelease reads information from MusicBrains and provides a Release record."""
|
126
|
+
|
127
|
+
_includes: Final[list[str]] = [
|
128
|
+
"artist-credits",
|
129
|
+
"artist-rels",
|
130
|
+
"isrcs",
|
131
|
+
"labels",
|
132
|
+
"recordings",
|
133
|
+
"recording-level-rels",
|
134
|
+
"release-groups",
|
135
|
+
"tags",
|
136
|
+
"work-rels",
|
137
|
+
"work-level-rels",
|
138
|
+
]
|
139
|
+
|
140
|
+
def __init__(self, release_id: str, *, verbose: bool = False) -> None:
|
141
|
+
"""Initialize an MusicBrainzRelease."""
|
142
|
+
self._release_id = release_id
|
143
|
+
self._verbose = verbose
|
144
|
+
self._session = MusicBrainzSession()
|
145
|
+
self._session.sleep()
|
146
|
+
self._release = mb.get_release_by_id(release_id, includes=self._includes)["release"]
|
147
|
+
self._release_record: records.Release | None = None
|
148
|
+
|
149
|
+
def get_release(self) -> records.Release:
|
150
|
+
"""Return the Release record."""
|
151
|
+
if self._release_record is None:
|
152
|
+
self._release_record = self._get_release()
|
153
|
+
return self._release_record
|
154
|
+
|
155
|
+
def _get_front_cover(self, size: int = 500) -> records.FrontCover | None:
|
156
|
+
# Return the FrontCover object (of None).
|
157
|
+
if self._release["cover-art-archive"]["front"] == "true":
|
158
|
+
self._session.sleep()
|
159
|
+
try:
|
160
|
+
return records.FrontCover(
|
161
|
+
data=mb.get_image_front(self._release["id"], size=size),
|
162
|
+
desc="front",
|
163
|
+
mime="image/jpeg",
|
164
|
+
)
|
165
|
+
except (
|
166
|
+
mb.musicbrainz.NetworkError,
|
167
|
+
mb.musicbrainz.ResponseError,
|
168
|
+
) as err:
|
169
|
+
log.warning("Error getting front cover: %s", err)
|
170
|
+
return None
|
171
|
+
|
172
|
+
def _get_genre(self, release_group_id: str, artist_id: str) -> str:
|
173
|
+
# Try to find the genre using the following methods (in order):
|
174
|
+
# - release-group user-genres
|
175
|
+
# - artist user-genres
|
176
|
+
# - release-group genres
|
177
|
+
# - release-group tags
|
178
|
+
# - user input
|
179
|
+
|
180
|
+
# user-genres and genres are not supported with the python library.
|
181
|
+
release_group = self._session.get_release_group_by_id(
|
182
|
+
release_group_id, includes=["genres", "user-genres"]
|
183
|
+
)
|
184
|
+
log.info("RELEASE_GROUP_GENRES: %s", release_group)
|
185
|
+
artist = self._session.get_artist_by_id(artist_id, includes=["genres", "user-genres"])
|
186
|
+
log.info("ARTIST_GENRES: %s", artist)
|
187
|
+
x_count: Callable[[Any], int] = lambda x: int(x["count"]) # noqa: E731
|
188
|
+
if release_group["user-genres"]:
|
189
|
+
return str(release_group["user-genres"][0]["name"])
|
190
|
+
if artist["user-genres"]:
|
191
|
+
return str(artist["user-genres"][0]["name"])
|
192
|
+
if release_group["genres"]:
|
193
|
+
return str(
|
194
|
+
next(g["name"] for g in sorted(release_group["genres"], key=x_count, reverse=True))
|
195
|
+
)
|
196
|
+
if artist["genres"]:
|
197
|
+
return str(
|
198
|
+
next(g["name"] for g in sorted(artist["genres"], key=x_count, reverse=True))
|
199
|
+
)
|
200
|
+
return text.input_("Genre not found; enter the genre [Alternative]: ") or "Alternative"
|
201
|
+
|
202
|
+
def _get_media(self) -> dict[int, records.Medium] | None:
|
203
|
+
# Return a dict of Media objects, keyed on number or position (or None).
|
204
|
+
media = {}
|
205
|
+
for medium in self._release.get("medium-list", []):
|
206
|
+
medium_number = int(medium.get("number") or medium.get("position"))
|
207
|
+
media[medium_number] = records.Medium(
|
208
|
+
formats=records.ListF([medium["format"]]),
|
209
|
+
titles=[text.fix(medium["title"])] if medium.get("title") else None,
|
210
|
+
track_count=medium["track-count"],
|
211
|
+
tracks=self._get_tracks(medium_number=medium_number),
|
212
|
+
)
|
213
|
+
return media
|
214
|
+
|
215
|
+
def _get_people(self) -> records.People | None: # noqa: C901, PLR0912
|
216
|
+
# Return a People object (or None).
|
217
|
+
arrangers, composers, conductors, engineers, lyricists = [], [], [], [], []
|
218
|
+
mixers, performers, producers, writers = [], [], [], []
|
219
|
+
for relation in self._release.get("artist-relation-list", []):
|
220
|
+
if log.getEffectiveLevel() == logging.DEBUG:
|
221
|
+
pprint.pp("== ARTIST-RELATION-LIST ===================")
|
222
|
+
pprint.pp(relation)
|
223
|
+
name = text.fix(relation["artist"]["name"])
|
224
|
+
type_ = relation["type"].lower()
|
225
|
+
if type_ == "arranger":
|
226
|
+
arrangers.append(name)
|
227
|
+
elif type_ == "composer":
|
228
|
+
composers.append(name)
|
229
|
+
elif type_ == "conductor":
|
230
|
+
conductors.append(name)
|
231
|
+
elif type_ == "engineer":
|
232
|
+
engineers.append(name)
|
233
|
+
elif type_ == "lyricist":
|
234
|
+
lyricists.append(name)
|
235
|
+
elif type_ == "mix":
|
236
|
+
mixers.append(name)
|
237
|
+
elif type_ == "instrument":
|
238
|
+
performers.append(
|
239
|
+
records.Performer(
|
240
|
+
name=name, instrument=text.fix(text.join(relation["attribute-list"]))
|
241
|
+
)
|
242
|
+
)
|
243
|
+
elif type_ == "vocal":
|
244
|
+
performers.append(records.Performer(name=name, instrument="lead vocals"))
|
245
|
+
if attrs := relation.get("attribute-list"):
|
246
|
+
if attrs := [x for x in attrs if x != "lead vocals"]:
|
247
|
+
performers.append(
|
248
|
+
records.Performer(name=name, instrument=text.fix(text.join(attrs)))
|
249
|
+
)
|
250
|
+
else:
|
251
|
+
performers.append(records.Performer(name=name, instrument="vocals"))
|
252
|
+
elif type_ == "producer":
|
253
|
+
producers.append(name)
|
254
|
+
elif type_ == "writer":
|
255
|
+
writers.append(name)
|
256
|
+
else:
|
257
|
+
log.warning("Unknown artist-relation type: %s", type_)
|
258
|
+
if (
|
259
|
+
engineers
|
260
|
+
or arrangers
|
261
|
+
or composers
|
262
|
+
or conductors
|
263
|
+
or lyricists
|
264
|
+
or mixers
|
265
|
+
or performers
|
266
|
+
or producers
|
267
|
+
or writers
|
268
|
+
):
|
269
|
+
return records.People(
|
270
|
+
arrangers=arrangers or None,
|
271
|
+
composers=composers or None,
|
272
|
+
conductors=conductors or None,
|
273
|
+
engineers=engineers or None,
|
274
|
+
lyricists=lyricists or None,
|
275
|
+
mixers=mixers or None,
|
276
|
+
performers=performers or None,
|
277
|
+
producers=producers or None,
|
278
|
+
writers=writers or None,
|
279
|
+
)
|
280
|
+
return None
|
281
|
+
|
282
|
+
def _get_release(self) -> records.Release:
|
283
|
+
# Return the Release object.
|
284
|
+
release = self._release
|
285
|
+
log.info("RELEASE %s", release)
|
286
|
+
if log.getEffectiveLevel() == logging.DEBUG:
|
287
|
+
pprint.pp("== RELEASE ===================")
|
288
|
+
pprint.pp(release)
|
289
|
+
release_group = release["release-group"]
|
290
|
+
(
|
291
|
+
album_artist_names_str,
|
292
|
+
_,
|
293
|
+
album_artist_sort_names,
|
294
|
+
artist_ids,
|
295
|
+
) = self._process_artist_credit(release["artist-credit"])
|
296
|
+
artist_phrase = text.fix(release.get("artist-credit-phrase", ""))
|
297
|
+
year = release.get("release-event-list", [{}])[0].get("date") or text.input_(
|
298
|
+
"Release year: "
|
299
|
+
)
|
300
|
+
album_type = [release_group["primary-type"].lower()]
|
301
|
+
if release_group["type"].lower() != album_type[0]:
|
302
|
+
album_type.append(release_group["type"].lower())
|
303
|
+
|
304
|
+
labels = list(dict.fromkeys([x["label"]["name"] for x in release["label-info-list"]]))
|
305
|
+
|
306
|
+
key = "catalog-number"
|
307
|
+
catalog_numbers = list(
|
308
|
+
dict.fromkeys([x[key] for x in release.get("label-info-list", []) if x.get(key)])
|
309
|
+
)
|
310
|
+
return records.Release(
|
311
|
+
album=text.fix(release["title"]),
|
312
|
+
album_artists=records.ListF([artist_phrase or album_artist_names_str]),
|
313
|
+
album_artists_sort=records.ListF([album_artist_sort_names]),
|
314
|
+
asins=[release["asin"]] if release.get("asin") else None,
|
315
|
+
barcodes=[release["barcode"]] if release.get("barcode") else None,
|
316
|
+
catalog_numbers=catalog_numbers or None,
|
317
|
+
date=year,
|
318
|
+
front_cover=self._get_front_cover(),
|
319
|
+
genres=records.ListF([self._get_genre(release_group["id"], artist_ids.first).title()]),
|
320
|
+
labels=labels,
|
321
|
+
media=self._get_media(),
|
322
|
+
medium_count=release.get("medium-count", 0),
|
323
|
+
musicbrainz_album_artist_ids=records.ListF(artist_ids),
|
324
|
+
musicbrainz_album_id=self._release_id,
|
325
|
+
musicbrainz_release_group_id=release_group["id"],
|
326
|
+
original_date=release_group.get("first-release-date", ""),
|
327
|
+
original_year=release_group.get("first-release-date", "").split("-")[0] or year,
|
328
|
+
people=self._get_people(),
|
329
|
+
release_countries=[release.get("country", "")],
|
330
|
+
release_statuses=[release.get("status", "").lower()],
|
331
|
+
release_types=album_type,
|
332
|
+
script="Latn",
|
333
|
+
source=records.Source.MUSICBRAINZ,
|
334
|
+
)
|
335
|
+
|
336
|
+
def _get_tracks(self, medium_number: int = 1) -> dict[int, records.Track] | None:
|
337
|
+
# Return a dict of Track objects, keyed on track number.
|
338
|
+
tracks = {}
|
339
|
+
for medium in self._release.get("medium-list", []):
|
340
|
+
if int(medium["position"]) == medium_number:
|
341
|
+
for track in medium["track-list"]:
|
342
|
+
track_number = int(track["position"])
|
343
|
+
recording = track["recording"]
|
344
|
+
artist, artist_list, artist_sort, artist_ids = self._process_artist_credit(
|
345
|
+
track.get("artist-credit") or recording["artist-credit"]
|
346
|
+
)
|
347
|
+
tracks[track_number] = records.Track(
|
348
|
+
artist=artist,
|
349
|
+
artists=artist_list,
|
350
|
+
artists_sort=[artist_sort],
|
351
|
+
isrcs=recording.get("isrc-list"),
|
352
|
+
musicbrainz_artist_ids=artist_ids,
|
353
|
+
musicbrainz_release_track_id=track["id"],
|
354
|
+
musicbrainz_track_id=recording["id"],
|
355
|
+
title=text.fix(track.get("title") or recording["title"]),
|
356
|
+
track_number=track_number,
|
357
|
+
)
|
358
|
+
break
|
359
|
+
return tracks or None
|
360
|
+
|
361
|
+
@staticmethod
|
362
|
+
def _process_artist_credit(
|
363
|
+
artist_credit: list[str],
|
364
|
+
) -> tuple[str, records.ListF, str, records.ListF]:
|
365
|
+
# Return artist info from an artist-credit list.
|
366
|
+
artist_names_str = ""
|
367
|
+
artist_names_list = records.ListF()
|
368
|
+
artist_sort_names = ""
|
369
|
+
artist_ids = records.ListF()
|
370
|
+
for credit in artist_credit:
|
371
|
+
if isinstance(credit, dict):
|
372
|
+
artist_names_str += text.fix(credit.get("name") or credit["artist"]["name"])
|
373
|
+
artist_names_list.append(text.fix(credit.get("name") or credit["artist"]["name"]))
|
374
|
+
artist_sort_names += text.fix(credit["artist"]["sort-name"])
|
375
|
+
artist_ids.append(text.fix(credit["artist"]["id"]))
|
376
|
+
else:
|
377
|
+
artist_names_str += text.fix(credit)
|
378
|
+
artist_sort_names += text.fix(credit)
|
379
|
+
return artist_names_str, artist_names_list, artist_sort_names, artist_ids
|
380
|
+
|
381
|
+
|
382
|
+
@dataclasses.dataclass
|
383
|
+
class Searcher:
|
384
|
+
"""Searcher objects contain data and methods for searching the MusicBrainz database."""
|
385
|
+
|
386
|
+
artist: str = ""
|
387
|
+
album: str = ""
|
388
|
+
disc_id: str = ""
|
389
|
+
disc_mcn: str = ""
|
390
|
+
disc_number: str = ""
|
391
|
+
mb_artist_id: str = ""
|
392
|
+
mb_release_id: str = ""
|
393
|
+
__mb_session: MusicBrainzSession | None = None
|
394
|
+
|
395
|
+
@property
|
396
|
+
def _mb_session(self) -> MusicBrainzSession:
|
397
|
+
if self.__mb_session is None:
|
398
|
+
self.__mb_session = MusicBrainzSession()
|
399
|
+
return self.__mb_session
|
400
|
+
|
401
|
+
def find_music_brains_release(self) -> records.Release | None:
|
402
|
+
"""Return a Release object (or None) based on a search."""
|
403
|
+
release_id = self.mb_release_id
|
404
|
+
if not release_id and self.disc_id:
|
405
|
+
self._mb_session.sleep()
|
406
|
+
result = mb.get_releases_by_discid(self.disc_id, includes=["artists"])
|
407
|
+
log.info("DISC: {result}")
|
408
|
+
if result.get("disc"):
|
409
|
+
release_id = result["disc"]["release-list"][0]["id"]
|
410
|
+
elif result.get("cdstub"):
|
411
|
+
print("A CD Stub exists for this disc, but no disc.")
|
412
|
+
|
413
|
+
if release_id:
|
414
|
+
log.info("RELEASE: https://musicbrainz.org/release/%s", release_id)
|
415
|
+
elif self.artist and self.album:
|
416
|
+
release_group_ids = self._get_release_group_ids()
|
417
|
+
log.info("RELEASE_GROUPS: {release_group_ids}")
|
418
|
+
release_id = self._prompt_release_id(release_group_ids)
|
419
|
+
else:
|
420
|
+
release_id = self._prompt_uuid("Musicbrainz Release ID: ")
|
421
|
+
|
422
|
+
return MusicBrainzRelease(release_id).get_release()
|
423
|
+
|
424
|
+
def _get_release_group_ids(self) -> list[str]:
|
425
|
+
# Return release groups that fuzzy-match the search criteria.
|
426
|
+
artist_l = self.artist.lower()
|
427
|
+
album_l = self.album.lower()
|
428
|
+
self._mb_session.sleep()
|
429
|
+
artist_list = mb.search_artists(query=artist_l, limit=500)["artist-list"]
|
430
|
+
if not artist_list:
|
431
|
+
return []
|
432
|
+
artist_id = artist_list[0]["id"]
|
433
|
+
self._mb_session.sleep()
|
434
|
+
release_group_list = mb.browse_release_groups(artist=artist_id, limit=500)[
|
435
|
+
"release-group-list"
|
436
|
+
]
|
437
|
+
log.info("RELEASE_GROUPS: %s", release_group_list)
|
438
|
+
if log.getEffectiveLevel() == logging.DEBUG:
|
439
|
+
pprint.pp("== RELEASE_GROUPS ===================")
|
440
|
+
pprint.pp(release_group_list)
|
441
|
+
return [
|
442
|
+
rg["id"]
|
443
|
+
for rg in release_group_list
|
444
|
+
if rg.get("primary-type") == "Album" and fuzz.ratio(album_l, rg["title"].lower()) > 80 # noqa: PLR2004
|
445
|
+
]
|
446
|
+
|
447
|
+
def _prompt_release_id(self, release_group_ids: list[str]) -> str:
|
448
|
+
# Prompt for, and return a MusicBrainz release ID.
|
449
|
+
print(
|
450
|
+
"\n\nWe found the following release group(s). Use the link(s) below to "
|
451
|
+
"find the release ID that best matches the audio files.\n"
|
452
|
+
)
|
453
|
+
for release_group_id in release_group_ids:
|
454
|
+
url = f"https://musicbrainz.org/release-group/{release_group_id}"
|
455
|
+
print(url)
|
456
|
+
webbrowser.open(url)
|
457
|
+
return self._prompt_uuid("\nRelease ID or URL: ")
|
458
|
+
|
459
|
+
@staticmethod
|
460
|
+
def _prompt_uuid(prompt: str) -> str:
|
461
|
+
# Prompt for, and return a UUID.
|
462
|
+
while True:
|
463
|
+
uuid = text.get_uuid(text.input_(prompt))
|
464
|
+
if uuid is not None:
|
465
|
+
return uuid
|
audiolibrarian/output.py
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
"""Screen output 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 sys
|
20
|
+
from types import TracebackType
|
21
|
+
|
22
|
+
|
23
|
+
class Dots:
|
24
|
+
"""Context Manager that outputs a message, and dots...
|
25
|
+
|
26
|
+
Example:
|
27
|
+
with Dots("Please wait...") as d:
|
28
|
+
for _ in range(10):
|
29
|
+
time.sleep(1) # or better, actually do some work here instead
|
30
|
+
d.dot()
|
31
|
+
"""
|
32
|
+
|
33
|
+
def __init__(self, message: str) -> None:
|
34
|
+
"""Initialize a Dots object."""
|
35
|
+
self._out(message)
|
36
|
+
|
37
|
+
def __enter__(self) -> "Dots":
|
38
|
+
"""Enter the context manager."""
|
39
|
+
return self
|
40
|
+
|
41
|
+
def __exit__(
|
42
|
+
self,
|
43
|
+
_: type[BaseException] | None,
|
44
|
+
__: BaseException | None,
|
45
|
+
___: TracebackType | None,
|
46
|
+
) -> None:
|
47
|
+
"""Exit the context manager."""
|
48
|
+
self._out("\n")
|
49
|
+
|
50
|
+
def dot(self) -> None:
|
51
|
+
"""Output a dot."""
|
52
|
+
self._out(".")
|
53
|
+
|
54
|
+
@staticmethod
|
55
|
+
def _out(message: str) -> None:
|
56
|
+
sys.stdout.write(message)
|
57
|
+
sys.stdout.flush()
|