1111import re
1212from contextlib import contextmanager , nullcontext
1313from 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
1623import unidecode
1724from confuse import NotFoundError
18- from typing_extensions import NotRequired
1925
2026from beets import config , logging
2127from beets .util import cached_classproperty
2228from beets .util .id_extractors import extract_release_id
2329
2430from .plugins import BeetsPlugin , find_plugins , notify_info_yielded
2531
32+ Ret = TypeVar ("Ret" )
33+ QueryType = Literal ["album" , "track" ]
34+
2635if 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.
3441log = 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
291306R = 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 ))
0 commit comments