228 lines
6.0 KiB
Python
228 lines
6.0 KiB
Python
"""
|
|
Defines the objects that are returned by adapter methods.
|
|
"""
|
|
import abc
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from functools import lru_cache, partial
|
|
from enum import Enum
|
|
from typing import (
|
|
Any,
|
|
Callable,
|
|
cast,
|
|
Dict,
|
|
Iterable,
|
|
List,
|
|
Optional,
|
|
Sequence,
|
|
Tuple,
|
|
TypeVar,
|
|
Union,
|
|
)
|
|
|
|
from fuzzywuzzy import fuzz
|
|
|
|
|
|
class Genre(abc.ABC):
|
|
name: str
|
|
song_count: Optional[int]
|
|
album_count: Optional[int]
|
|
|
|
|
|
class Album(abc.ABC):
|
|
"""
|
|
The ``id`` field is optional, because there are some situations where an adapter
|
|
(such as Subsonic) sends an album name, but not an album ID.
|
|
"""
|
|
|
|
name: str
|
|
id: Optional[str]
|
|
artist: Optional["Artist"]
|
|
cover_art: Optional[str]
|
|
created: Optional[datetime]
|
|
duration: Optional[timedelta]
|
|
genre: Optional[Genre]
|
|
play_count: Optional[int]
|
|
song_count: Optional[int]
|
|
songs: Optional[Sequence["Song"]]
|
|
starred: Optional[datetime]
|
|
year: Optional[int]
|
|
|
|
|
|
class Artist(abc.ABC):
|
|
"""
|
|
The ``id`` field is optional, because there are some situations where an adapter
|
|
(such as Subsonic) sends an artist name, but not an artist ID. This especially
|
|
happens when there are multiple artists.
|
|
"""
|
|
|
|
name: str
|
|
id: Optional[str]
|
|
album_count: Optional[int]
|
|
artist_image_url: Optional[str]
|
|
starred: Optional[datetime]
|
|
albums: Optional[Sequence[Album]]
|
|
|
|
similar_artists: Optional[Sequence["Artist"]] = None
|
|
biography: Optional[str] = None
|
|
music_brainz_id: Optional[str] = None
|
|
last_fm_url: Optional[str] = None
|
|
|
|
|
|
class Directory(abc.ABC):
|
|
"""
|
|
The special directory with ``name`` and ``id`` should be used to indicate the
|
|
top-level directory.
|
|
"""
|
|
|
|
id: str
|
|
name: Optional[str]
|
|
parent_id: Optional[str]
|
|
children: Sequence[Union["Directory", "Song"]]
|
|
|
|
|
|
class Song(abc.ABC):
|
|
id: str
|
|
title: str
|
|
path: Optional[str]
|
|
parent_id: Optional[str]
|
|
duration: Optional[timedelta]
|
|
|
|
album: Optional[Album]
|
|
artist: Optional[Artist]
|
|
genre: Optional[Genre]
|
|
|
|
track: Optional[int]
|
|
disc_number: Optional[int]
|
|
year: Optional[int]
|
|
cover_art: Optional[str]
|
|
size: Optional[int]
|
|
user_rating: Optional[int]
|
|
starred: Optional[datetime]
|
|
|
|
|
|
class Playlist(abc.ABC):
|
|
id: str
|
|
name: str
|
|
song_count: Optional[int]
|
|
duration: Optional[timedelta]
|
|
songs: Sequence[Song]
|
|
created: Optional[datetime]
|
|
changed: Optional[datetime]
|
|
comment: Optional[str]
|
|
owner: Optional[str]
|
|
public: Optional[bool]
|
|
cover_art: Optional[str]
|
|
|
|
|
|
class PlayQueue(abc.ABC):
|
|
songs: Sequence[Song]
|
|
position: timedelta
|
|
username: Optional[str]
|
|
changed: Optional[datetime]
|
|
changed_by: Optional[str]
|
|
value: Optional[str]
|
|
current_index: Optional[int]
|
|
|
|
|
|
@lru_cache(maxsize=8192)
|
|
def similarity_ratio(query: str, string: str) -> int:
|
|
"""
|
|
Return the :class:`fuzzywuzzy.fuzz.partial_ratio` between the ``query`` and
|
|
the given ``string``.
|
|
|
|
This ends up being called quite a lot, so the result is cached in an LRU
|
|
cache using :class:`functools.lru_cache`.
|
|
|
|
:param query: the query string
|
|
:param string: the string to compare to the query string
|
|
"""
|
|
return fuzz.partial_ratio(query, string)
|
|
|
|
|
|
class SearchResult:
|
|
"""
|
|
An object representing the aggregate results of a search which can include
|
|
both server and local results.
|
|
"""
|
|
|
|
class Kind(Enum):
|
|
ARTIST = 0
|
|
ALBUM = 1
|
|
SONG = 2
|
|
PLAYLIST = 2
|
|
|
|
ValueType = Union[Artist, Album, Song, Playlist]
|
|
|
|
def __init__(self, query: str = None):
|
|
self.query = query
|
|
self.similiarity_partial = partial(
|
|
similarity_ratio, self.query.lower() if self.query else ""
|
|
)
|
|
self._results: Dict[Tuple[Kind, str], ValueType] = {}
|
|
|
|
def __repr__(self) -> str:
|
|
fields = ("query", "_results")
|
|
formatted_fields = map(lambda f: f"{f}={getattr(self, f)}", fields)
|
|
return f"<SearchResult {' '.join(formatted_fields)}>"
|
|
|
|
def add_results(self, result_type: str, results: Iterable):
|
|
"""Adds the ``results`` to the ``_result_type`` set."""
|
|
if results is None:
|
|
return
|
|
|
|
for result in results:
|
|
if result_type == 'artists':
|
|
kind = self.Kind.ARTIST
|
|
elif result_type == 'albums':
|
|
kind = self.Kind.ALBUM
|
|
elif result_type == 'songs':
|
|
kind = self.Kind.SONG
|
|
elif result_type == 'playlists':
|
|
kind = self.Kind.PLAYLIST
|
|
else:
|
|
assert False
|
|
|
|
self._results[(kind, result.id)] = result
|
|
|
|
def update(self, other: "SearchResult"):
|
|
assert self.query == other.query
|
|
self._results.update(other._results)
|
|
|
|
def _transform(self, kind: Kind, value: ValueType) -> Tuple[str, ...]:
|
|
if kind is self.Kind.ARTIST:
|
|
return (value.name,)
|
|
elif kind is self.Kind.ALBUM:
|
|
return (value.name, value.artist and value.artist.name)
|
|
elif kind is self.Kind.SONG:
|
|
return (value.title, value.artist and value.artist.name)
|
|
elif kind is self.Kind.PLAYLIST:
|
|
return (value.name,)
|
|
else:
|
|
assert False
|
|
|
|
def get_results(self) -> List[Tuple[Kind, ValueType]]:
|
|
assert self.query
|
|
|
|
all_results = []
|
|
for (kind, _), value in self._results.items():
|
|
try:
|
|
transformed = self._transform(kind, value)
|
|
except Exception:
|
|
continue
|
|
|
|
max_similarity = max(
|
|
(self.similiarity_partial(t.lower()) for t in transformed
|
|
if t is not None),
|
|
default=0)
|
|
|
|
if max_similarity < 60:
|
|
continue
|
|
|
|
all_results.append((max_similarity, (kind, value)))
|
|
|
|
all_results.sort(key=lambda rx: rx[0], reverse=True)
|
|
|
|
logging.debug(similarity_ratio.cache_info())
|
|
return [r for _, r in all_results]
|