Skip to content

Commit 8489835

Browse files
authored
Merge branch 'beetbox:master' into play-randomize
2 parents 37ebbbc + 3c48b0c commit 8489835

File tree

13 files changed

+336
-261
lines changed

13 files changed

+336
-261
lines changed

beets/metadata_plugins.py

Lines changed: 96 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,32 @@
1111
import re
1212
from contextlib import contextmanager, nullcontext
1313
from functools import cache, cached_property, wraps
14-
from typing import TYPE_CHECKING, Generic, Literal, TypedDict, TypeVar
14+
from typing import (
15+
TYPE_CHECKING,
16+
Generic,
17+
Literal,
18+
NamedTuple,
19+
TypedDict,
20+
TypeVar,
21+
)
1522

1623
import unidecode
1724
from confuse import NotFoundError
18-
from typing_extensions import NotRequired
1925

2026
from beets import config, logging
2127
from beets.util import cached_classproperty
2228
from beets.util.id_extractors import extract_release_id
2329

2430
from .plugins import BeetsPlugin, find_plugins, notify_info_yielded
2531

32+
Ret = TypeVar("Ret")
33+
QueryType = Literal["album", "track"]
34+
2635
if TYPE_CHECKING:
2736
from collections.abc import Callable, Iterable, Iterator, Sequence
2837

2938
from .autotag.hooks import AlbumInfo, Item, TrackInfo
3039

31-
Ret = TypeVar("Ret")
32-
3340
# Global logger.
3441
log = logging.getLogger("beets")
3542

@@ -198,7 +205,7 @@ def item_candidates(
198205
"""
199206
raise NotImplementedError
200207

201-
def albums_for_ids(self, ids: Sequence[str]) -> Iterable[AlbumInfo | None]:
208+
def albums_for_ids(self, ids: Iterable[str]) -> Iterable[AlbumInfo | None]:
202209
"""Batch lookup of album metadata for a list of album IDs.
203210
204211
Given a list of album identifiers, yields corresponding AlbumInfo objects.
@@ -209,7 +216,7 @@ def albums_for_ids(self, ids: Sequence[str]) -> Iterable[AlbumInfo | None]:
209216

210217
return (self.album_for_id(id) for id in ids)
211218

212-
def tracks_for_ids(self, ids: Sequence[str]) -> Iterable[TrackInfo | None]:
219+
def tracks_for_ids(self, ids: Iterable[str]) -> Iterable[TrackInfo | None]:
213220
"""Batch lookup of track metadata for a list of track IDs.
214221
215222
Given a list of track identifiers, yields corresponding TrackInfo objects.
@@ -283,9 +290,17 @@ class IDResponse(TypedDict):
283290
id: str
284291

285292

286-
class SearchFilter(TypedDict):
287-
artist: NotRequired[str]
288-
album: NotRequired[str]
293+
class SearchParams(NamedTuple):
294+
"""Bundle normalized search context passed to provider search hooks.
295+
296+
Shared search orchestration constructs this value so plugin hooks receive
297+
one object describing search intent, query text, and provider filters.
298+
"""
299+
300+
query_type: QueryType
301+
query: str
302+
filters: dict[str, str]
303+
limit: int
289304

290305

291306
R = TypeVar("R", bound=IDResponse)
@@ -312,77 +327,92 @@ def __init__(self, *args, **kwargs) -> None:
312327
)
313328

314329
@abc.abstractmethod
315-
def _search_api(
330+
def get_search_query_with_filters(
316331
self,
317-
query_type: Literal["album", "track"],
318-
filters: SearchFilter,
319-
query_string: str = "",
320-
) -> Sequence[R]:
321-
"""Perform a search on the API.
332+
query_type: QueryType,
333+
items: Sequence[Item],
334+
artist: str,
335+
name: str,
336+
va_likely: bool,
337+
) -> tuple[str, dict[str, str]]:
338+
"""Build query text and API filters for a provider search.
339+
340+
Subclasses can override this hook when their API requires a query format
341+
or filter set that differs from the default text-based construction.
342+
343+
:param query_type: The type of query to perform. Either *album* or *track*
344+
:param items: List of items the search is being performed for
345+
:param artist: Artist name
346+
:param name: Album or track name, depending on ``query_type``
347+
:param va_likely: Whether the search is likely to be for various artists
348+
:return: Tuple of (``query`` text, ``filters`` dict) to use for the
349+
search API call
350+
"""
351+
352+
@abc.abstractmethod
353+
def get_search_response(self, params: SearchParams) -> Sequence[R]:
354+
"""Fetch raw search results for a provider request.
322355
323-
:param query_type: The type of query to perform.
324-
:param filters: A dictionary of filters to apply to the search.
325-
:param query_string: Additional query to include in the search.
356+
Implementations should return records containing source IDs so shared
357+
candidate resolution can perform ID-based album and track lookups.
326358
327-
Should return a list of identifiers for the requested type (album or track).
359+
:param params: :py:namedtuple:`~SearchParams` named tuple
360+
:return: Sequence of IDResponse dicts containing at least an "id" key for each
328361
"""
362+
329363
raise NotImplementedError
330364

365+
def _search_api(
366+
self, query_type: QueryType, query: str, filters: dict[str, str]
367+
) -> Sequence[R]:
368+
"""Run shared provider search orchestration and return ID-bearing results.
369+
370+
This path applies optional query normalization and default limits, then
371+
delegates API access to provider hooks with consistent logging and
372+
failure handling.
373+
"""
374+
if self.config["search_query_ascii"].get():
375+
query = unidecode.unidecode(query)
376+
377+
limit = self.config["search_limit"].get(int)
378+
params = SearchParams(query_type, query, filters, limit)
379+
380+
self._log.debug("Searching for '{}' with {}", query, filters)
381+
try:
382+
response_data = self.get_search_response(params)
383+
except Exception as e:
384+
if config["raise_on_error"].get(bool):
385+
raise
386+
self._log.error(
387+
"Error searching {.data_source}: {}", self, e, exc_info=True
388+
)
389+
return ()
390+
391+
self._log.debug("Found {} result(s)", len(response_data))
392+
return response_data
393+
394+
def _get_candidates(
395+
self, query_type: QueryType, *args, **kwargs
396+
) -> Sequence[R]:
397+
"""Resolve query hooks and execute one provider search request."""
398+
399+
return self._search_api(
400+
query_type,
401+
*self.get_search_query_with_filters(query_type, *args, **kwargs),
402+
)
403+
331404
def candidates(
332405
self,
333406
items: Sequence[Item],
334407
artist: str,
335408
album: str,
336409
va_likely: bool,
337410
) -> Iterable[AlbumInfo]:
338-
query_filters: SearchFilter = {}
339-
if album:
340-
query_filters["album"] = album
341-
if not va_likely:
342-
query_filters["artist"] = artist
343-
344-
results = self._search_api("album", query_filters)
345-
if not results:
346-
return []
347-
348-
return filter(
349-
None, self.albums_for_ids([result["id"] for result in results])
350-
)
411+
results = self._get_candidates("album", items, artist, album, va_likely)
412+
return filter(None, self.albums_for_ids(r["id"] for r in results))
351413

352414
def item_candidates(
353415
self, item: Item, artist: str, title: str
354416
) -> Iterable[TrackInfo]:
355-
results = self._search_api(
356-
"track", {"artist": artist}, query_string=title
357-
)
358-
if not results:
359-
return []
360-
361-
return filter(
362-
None,
363-
self.tracks_for_ids([result["id"] for result in results if result]),
364-
)
365-
366-
def _construct_search_query(
367-
self, filters: SearchFilter, query_string: str
368-
) -> str:
369-
"""Construct a query string with the specified filters and keywords to
370-
be provided to the spotify (or similar) search API.
371-
372-
The returned format was initially designed for spotify's search API but
373-
we found is also useful with other APIs that support similar query structures.
374-
see `spotify <https://developer.spotify.com/documentation/web-api/reference/search>`_
375-
and `deezer <https://developers.deezer.com/api/search>`_.
376-
377-
:param filters: Field filters to apply.
378-
:param query_string: Query keywords to use.
379-
:return: Query string to be provided to the search API.
380-
"""
381-
382-
components = [query_string, *(f"{k}:'{v}'" for k, v in filters.items())]
383-
query = " ".join(filter(None, components))
384-
385-
if self.config["search_query_ascii"].get():
386-
query = unidecode.unidecode(query)
387-
388-
return query
417+
results = self._get_candidates("track", [item], artist, title, False)
418+
return filter(None, self.tracks_for_ids(r["id"] for r in results))

beetsplug/_utils/musicbrainz.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929

3030
from requests import Response
3131

32+
from beets.metadata_plugins import IDResponse
33+
3234
from .._typing import JSONDict
3335

3436
log = logging.getLogger("beets")
@@ -232,7 +234,7 @@ def search(
232234
entity: Entity,
233235
filters: dict[str, str],
234236
**kwargs: Unpack[SearchKwargs],
235-
) -> list[JSONDict]:
237+
) -> list[IDResponse]:
236238
"""Search for MusicBrainz entities matching the given filters.
237239
238240
* Query is constructed by combining the provided filters using AND logic

beetsplug/deezer.py

Lines changed: 28 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import collections
2020
import time
21-
from typing import TYPE_CHECKING, ClassVar, Literal
21+
from typing import TYPE_CHECKING, ClassVar
2222

2323
import requests
2424

@@ -31,7 +31,7 @@
3131
from collections.abc import Sequence
3232

3333
from beets.library import Item, Library
34-
from beets.metadata_plugins import SearchFilter
34+
from beets.metadata_plugins import QueryType, SearchParams
3535

3636
from ._typing import JSONDict
3737

@@ -217,58 +217,34 @@ def _get_track(self, track_data: JSONDict) -> TrackInfo:
217217
deezer_updated=time.time(),
218218
)
219219

220-
def _search_api(
220+
def get_search_query_with_filters(
221221
self,
222-
query_type: Literal[
223-
"album",
224-
"track",
225-
"artist",
226-
"history",
227-
"playlist",
228-
"podcast",
229-
"radio",
230-
"user",
231-
],
232-
filters: SearchFilter,
233-
query_string: str = "",
234-
) -> Sequence[IDResponse]:
235-
"""Query the Deezer Search API for the specified ``query_string``, applying
236-
the provided ``filters``.
237-
238-
:param filters: Field filters to apply.
239-
:param query_string: Additional query to include in the search.
240-
:return: JSON data for the class:`Response <Response>` object or None
241-
if no search results are returned.
242-
"""
243-
query = self._construct_search_query(
244-
query_string=query_string, filters=filters
245-
)
246-
self._log.debug("Searching {.data_source} for '{}'", self, query)
247-
try:
248-
response = requests.get(
249-
f"{self.search_url}{query_type}",
250-
params={
251-
"q": query,
252-
"limit": self.config["search_limit"].get(),
253-
},
254-
timeout=10,
255-
)
256-
response.raise_for_status()
257-
except requests.exceptions.RequestException as e:
258-
self._log.error(
259-
"Error fetching data from {.data_source} API\n Error: {}",
260-
self,
261-
e,
262-
)
263-
return ()
264-
response_data: Sequence[IDResponse] = response.json().get("data", [])
265-
self._log.debug(
266-
"Found {} result(s) from {.data_source} for '{}'",
267-
len(response_data),
268-
self,
269-
query,
222+
query_type: QueryType,
223+
items: Sequence[Item],
224+
artist: str,
225+
name: str,
226+
va_likely: bool,
227+
) -> tuple[str, dict[str, str]]:
228+
query = f'album:"{name}"' if query_type == "album" else name
229+
if query_type == "track" or not va_likely:
230+
query += f' artist:"{artist}"'
231+
232+
return query, {}
233+
234+
def get_search_response(self, params: SearchParams) -> list[IDResponse]:
235+
"""Search Deezer and return the raw result payload entries."""
236+
237+
response = requests.get(
238+
f"{self.search_url}{params.query_type}",
239+
params={
240+
**params.filters,
241+
"q": params.query,
242+
"limit": str(params.limit),
243+
},
244+
timeout=10,
270245
)
271-
return response_data
246+
response.raise_for_status()
247+
return response.json()["data"]
272248

273249
def deezerupdate(self, items: Sequence[Item], write: bool):
274250
"""Obtain rank information from Deezer."""

0 commit comments

Comments
 (0)