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,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
@@ -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()