-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Make musicbrainz plugin talk to musicbrainz directly #6052
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
snejus
wants to merge
12
commits into
master
Choose a base branch
from
use-musicbrainz-api-directly
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
475ee94
Move TimeoutSession under beetsplug._utils
snejus 15f3a74
Define MusicBrainzAPI class with rate limiting
snejus 310f2fe
Add missing blame ignore revs from musicbrainz plugin
snejus c42db02
Move pseudo release lookup under the plugin
snejus 0325b12
musicbrainz: lookup release directly
snejus fa3afed
musicbrainz: lookup recordings directly
snejus 4ee13c7
musicbrainz: search directly
snejus 707e4ad
musicbrainz: browse directly
snejus b469788
musicbrainz: access the custom server directly, if configured
snejus 6ef9359
musicbrainz: remove error handling
snejus 065363c
Make musicbrainzngs dependency optional and requests required
snejus f30d96e
Refactor HTTP request handling with RequestHandler base class
snejus File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
from __future__ import annotations | ||
|
||
import atexit | ||
import importlib | ||
import threading | ||
from contextlib import contextmanager | ||
from functools import cached_property | ||
from http import HTTPStatus | ||
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Protocol, TypeVar | ||
|
||
import requests | ||
|
||
if TYPE_CHECKING: | ||
from collections.abc import Iterator | ||
|
||
|
||
class BeetsHTTPError(requests.exceptions.HTTPError): | ||
STATUS: ClassVar[HTTPStatus] | ||
|
||
def __init__(self, *args, **kwargs) -> None: | ||
super().__init__( | ||
f"HTTP Error: {self.STATUS.value} {self.STATUS.phrase}", | ||
*args, | ||
**kwargs, | ||
) | ||
|
||
|
||
class HTTPNotFoundError(BeetsHTTPError): | ||
STATUS = HTTPStatus.NOT_FOUND | ||
|
||
|
||
class Closeable(Protocol): | ||
"""Protocol for objects that have a close method.""" | ||
|
||
def close(self) -> None: ... | ||
|
||
|
||
C = TypeVar("C", bound=Closeable) | ||
|
||
|
||
class SingletonMeta(type, Generic[C]): | ||
"""Metaclass ensuring a single shared instance per class. | ||
|
||
Creates one instance per class type on first instantiation, reusing it | ||
for all subsequent calls. Automatically registers cleanup on program exit | ||
for proper resource management. | ||
""" | ||
|
||
_instances: ClassVar[dict[type[Any], Any]] = {} | ||
_lock: ClassVar[threading.Lock] = threading.Lock() | ||
|
||
def __call__(cls, *args: Any, **kwargs: Any) -> C: | ||
if cls not in cls._instances: | ||
with cls._lock: | ||
if cls not in SingletonMeta._instances: | ||
instance = super().__call__(*args, **kwargs) | ||
SingletonMeta._instances[cls] = instance | ||
atexit.register(instance.close) | ||
return SingletonMeta._instances[cls] | ||
|
||
|
||
class TimeoutSession(requests.Session, metaclass=SingletonMeta): | ||
"""HTTP session with automatic timeout and status checking. | ||
|
||
Extends requests.Session to provide sensible defaults for beets HTTP | ||
requests: automatic timeout enforcement, status code validation, and | ||
proper user agent identification. | ||
""" | ||
|
||
def __init__(self, *args, **kwargs) -> None: | ||
super().__init__(*args, **kwargs) | ||
beets_version = importlib.metadata.version("beets") | ||
self.headers["User-Agent"] = f"beets/{beets_version} https://beets.io/" | ||
|
||
def request(self, *args, **kwargs): | ||
"""Execute HTTP request with automatic timeout and status validation. | ||
|
||
Ensures all requests have a timeout (defaults to 10 seconds) and raises | ||
an exception for HTTP error status codes. | ||
""" | ||
kwargs.setdefault("timeout", 10) | ||
r = super().request(*args, **kwargs) | ||
r.raise_for_status() | ||
|
||
return r | ||
|
||
|
||
class RequestHandler: | ||
"""Manages HTTP requests with custom error handling and session management. | ||
|
||
Provides a reusable interface for making HTTP requests with automatic | ||
conversion of standard HTTP errors to beets-specific exceptions. Supports | ||
custom session types and error mappings that can be overridden by | ||
subclasses. | ||
""" | ||
|
||
session_type: ClassVar[type[TimeoutSession]] = TimeoutSession | ||
explicit_http_errors: ClassVar[list[type[BeetsHTTPError]]] = [ | ||
HTTPNotFoundError | ||
] | ||
|
||
@cached_property | ||
def session(self) -> Any: | ||
"""Lazily initialize and cache the HTTP session.""" | ||
return self.session_type() | ||
|
||
def status_to_error( | ||
self, code: int | ||
) -> type[requests.exceptions.HTTPError] | None: | ||
"""Map HTTP status codes to beets-specific exception types. | ||
|
||
Searches the configured explicit HTTP errors for a matching status code. | ||
Returns None if no specific error type is registered for the given code. | ||
""" | ||
return next( | ||
(e for e in self.explicit_http_errors if e.STATUS == code), None | ||
) | ||
|
||
@contextmanager | ||
def handle_http_error(self) -> Iterator[None]: | ||
"""Convert standard HTTP errors to beets-specific exceptions. | ||
|
||
Wraps operations that may raise HTTPError, automatically translating | ||
recognized status codes into their corresponding beets exception types. | ||
Unrecognized errors are re-raised unchanged. | ||
""" | ||
try: | ||
yield | ||
except requests.exceptions.HTTPError as e: | ||
if beets_error := self.status_to_error(e.response.status_code): | ||
raise beets_error(response=e.response) from e | ||
raise | ||
|
||
def request(self, method: str, *args, **kwargs): | ||
"""Perform HTTP request using the session with automatic error handling. | ||
|
||
Delegates to the underlying session method while converting recognized | ||
HTTP errors to beets-specific exceptions through the error handler. | ||
""" | ||
with self.handle_http_error(): | ||
return getattr(self.session, method)(*args, **kwargs) | ||
|
||
def fetch_json(self, *args, **kwargs): | ||
"""Fetch and parse JSON data from an HTTP endpoint.""" | ||
return self.request("get", *args, **kwargs).json() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.