Skip to content

Commit

Permalink
switch ItemMatcher inheritance to composite pattern on remote process…
Browse files Browse the repository at this point in the history
…ors (#65)

* switch ItemMatcher inheritance to composite pattern on remote processors

* fix typo in release history
  • Loading branch information
geo-martino authored Apr 9, 2024
1 parent 67dd5ed commit 07b23af
Show file tree
Hide file tree
Showing 5 changed files with 118 additions and 57 deletions.
2 changes: 2 additions & 0 deletions docs/release-history.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ Changed
* Renamed 'kind' property on :py:class:`.LocalTrack` to 'type' to avoid clashing property names
* :py:class:`.ItemMatcher`, :py:class:`.RemoteItemChecker`, and :py:class:`.RemoteItemSearcher` now accept
all MusifyItem types that may have their URI property set manually
* :py:class:`.RemoteItemChecker` and :py:class:`.RemoteItemSearcher` no longer inherit from :py:class:`.ItemMatcher`.
Composite pattern used instead.
* :py:class:`.ItemSorter` now shuffles randomly on unsupported types
+ prioritises fields settings over shuffle settings
* :py:meth:`.Comparer._in_range` now uses inclusive range i.e. ``a <= x <= b`` where ``x`` is the value to compare
Expand Down
71 changes: 47 additions & 24 deletions musify/libraries/remote/core/processors/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
Provides the user the ability to modify associated IDs using a Remote player as an interface for
reviewing matches through temporary playlist creation.
"""
import logging
from collections import Counter
from collections.abc import Sequence, Collection, Iterator
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Any

from musify import PROGRAM_NAME
from musify.core.base import MusifyItemSettable
Expand All @@ -19,6 +21,7 @@
from musify.libraries.remote.core.factory import RemoteObjectFactory
from musify.libraries.remote.core.processors.search import RemoteItemSearcher
from musify.log import REPORT
from musify.log.logger import MusifyLogger
from musify.processors.base import InputProcessor
from musify.processors.match import ItemMatcher
from musify.utils import get_max_width, align_string
Expand All @@ -37,7 +40,7 @@ class ItemCheckResult[T: MusifyItemSettable](Result):
skipped: Sequence[T] = field(default=tuple())


class RemoteItemChecker(ItemMatcher, InputProcessor):
class RemoteItemChecker(InputProcessor):
"""
Runs operations for checking the URIs associated with a collection of items.
Expand All @@ -51,6 +54,8 @@ class RemoteItemChecker(ItemMatcher, InputProcessor):
to determine how they wish to deal with these items.
* Operation completes once user exists or all items have an associated URI.
:param matcher: The :py:class:`ItemMatcher` to use when comparing any changes made by the user in remote playlists
during the checking operation
:param object_factory: The :py:class:`RemoteObjectFactory` to use when creating new remote objects.
This must have a :py:class:`RemoteAPI` assigned for this processor to work as expected.
:param interval: Stop creating playlists after this many playlists have been created and pause for user input.
Expand Down Expand Up @@ -79,18 +84,28 @@ def api(self) -> RemoteAPI:
return self.factory.api

def __init__(
self, object_factory: RemoteObjectFactory, interval: int = 10, allow_karaoke: bool = ALLOW_KARAOKE_DEFAULT
self,
matcher: ItemMatcher,
object_factory: RemoteObjectFactory,
interval: int = 10,
allow_karaoke: bool = ALLOW_KARAOKE_DEFAULT
):
super().__init__()

# noinspection PyTypeChecker
#: The :py:class:`MusifyLogger` for this object
self.logger: MusifyLogger = logging.getLogger(__name__)

#: The :py:class:`ItemMatcher` to use when comparing any changes made by the user in remote playlists
#: during the checking operation
self.matcher = matcher
#: The :py:class:`RemoteObjectFactory` to use when creating new remote objects.
self.factory = object_factory
#: Stop creating playlists after this many playlists have been created and pause for user input
self.interval = interval
#: Allow karaoke items when matching on switched items
self.allow_karaoke = allow_karaoke

#: The :py:class:`RemoteObjectFactory` to use when creating new remote objects.
self.factory = object_factory

#: Map of playlist names to their URLs for created temporary playlists
self._playlist_name_urls = {}
#: Map of playlist names to the collection of items added for created temporary playlists
Expand Down Expand Up @@ -294,7 +309,7 @@ def _check_uri(self) -> None:
skip_hold = self._skip
self._skip = False
for name, collection in self._playlist_name_collection.items():
self._log_padded([name, f"{len(collection):>6} total items"], pad='>')
self.matcher.log_messages([name, f"{len(collection):>6} total items"], pad='>')

while True:
self._match_to_remote(name=name)
Expand All @@ -305,9 +320,9 @@ def _check_uri(self) -> None:
unavailable = tuple(item for item in collection if item.has_uri is False)
skipped = tuple(item for item in collection if item.has_uri is None)

self._log_padded([name, f"{len(self._switched):>6} items switched"], pad='<')
self._log_padded([name, f"{len(unavailable):>6} items unavailable"])
self._log_padded([name, f"{len(skipped):>6} items skipped"])
self.matcher.log_messages([name, f"{len(self._switched):>6} items switched"], pad='<')
self.matcher.log_messages([name, f"{len(unavailable):>6} items unavailable"])
self.matcher.log_messages([name, f"{len(skipped):>6} items skipped"])

self._final_switched += self._switched
self._final_unavailable += unavailable
Expand Down Expand Up @@ -344,7 +359,7 @@ def _match_to_remote(self, name: str) -> None:

if len(added) + len(removed) + len(missing) == 0:
if len(source_valid) == len(remote_valid):
self._log_padded([name, "Playlist unchanged and no missing URIs, skipping match"])
self.matcher.log_messages([name, "Playlist unchanged and no missing URIs, skipping match"])
return

# if item collection originally contained duplicate URIS and one or more of the duplicates were removed,
Expand All @@ -354,17 +369,17 @@ def _match_to_remote(self, name: str) -> None:
if remote_counts.get(uri) != count:
missing.extend([item for item in source_valid if item.uri == uri])

self._log_padded([name, f"{len(added):>6} items added"])
self._log_padded([name, f"{len(removed):>6} items removed"])
self._log_padded([name, f"{len(missing):>6} items in source missing URI"])
self._log_padded([name, f"{len(source_valid) - len(remote_valid):>6} total difference"])
self.matcher.log_messages([name, f"{len(added):>6} items added"])
self.matcher.log_messages([name, f"{len(removed):>6} items removed"])
self.matcher.log_messages([name, f"{len(missing):>6} items in source missing URI"])
self.matcher.log_messages([name, f"{len(source_valid) - len(remote_valid):>6} total difference"])

remaining = removed + missing
count_start = len(remaining)
with ThreadPoolExecutor(thread_name_prefix="checker") as executor:
tasks: Iterator[tuple[MusifyItemSettable, MusifyItemSettable | None]] = executor.map(
lambda item: (
item, self.match(item, results=added, match_on=[Fields.TITLE], allow_karaoke=self.allow_karaoke)
item, self.matcher(item, results=added, match_on=[Fields.TITLE], allow_karaoke=self.allow_karaoke)
),
remaining if added else ()
)
Expand All @@ -381,8 +396,8 @@ def _match_to_remote(self, name: str) -> None:

self._remaining = removed + missing
count_final = len(self._remaining)
self._log_padded([name, f"{count_start - count_final:>6} items switched"])
self._log_padded([name, f"{count_final:>6} items still not found"])
self.matcher.log_messages([name, f"{count_start - count_final:>6} items switched"])
self.matcher.log_messages([name, f"{count_final:>6} items still not found"])

def _match_to_input(self, name: str) -> None:
"""
Expand All @@ -408,13 +423,13 @@ def _match_to_input(self, name: str) -> None:
help_text = self._format_help_text(options=options, header=header)
help_text += "OR enter a custom URI/URL/ID for this item\n"

self._log_padded([name, f"Getting user input for {len(self._remaining)} items"])
self.matcher.log_messages([name, f"Getting user input for {len(self._remaining)} items"])
max_width = get_max_width({item.name for item in self._remaining})

print("\n" + help_text)
for item in self._remaining.copy():
while current_input is not None and item in self._remaining: # while item not matched or skipped
self._log_padded([name, f"{len(self._remaining):>6} remaining items"])
self.matcher.log_messages([name, f"{len(self._remaining):>6} remaining items"])
if 'a' not in current_input:
current_input = self._get_user_input(align_string(item.name, max_width=max_width))

Expand All @@ -428,24 +443,24 @@ def _match_to_input(self, name: str) -> None:

def _match_item_to_input(self, name: str, item: MusifyItemSettable, current_input: str) -> str | None:
if current_input.casefold() == 's' or current_input.casefold() == 'q': # quit/skip
self._log_padded([name, "Skipping all loops"], pad="<")
self.matcher.log_messages([name, "Skipping all loops"], pad="<")
self._quit = current_input.casefold() == 'q' or self._quit
self._skip = current_input.casefold() == 's' or self._skip
self._remaining.clear()
return

elif current_input.casefold().replace('a', '') == 'u': # mark item as unavailable
self._log_padded([name, "Marking as unavailable"], pad="<")
self.matcher.log_messages([name, "Marking as unavailable"], pad="<")
item.uri = self.api.wrangler.unavailable_uri_dummy
self._remaining.remove(item)

elif current_input.casefold().replace('a', '') == 'n': # leave item without URI and unprocessed
self._log_padded([name, "Skipping"], pad="<")
self.matcher.log_messages([name, "Skipping"], pad="<")
item.uri = None
self._remaining.remove(item)

elif current_input.casefold() == 'r': # return to former 'while' loop
self._log_padded([name, "Refreshing playlist metadata and restarting loop"])
self.matcher.log_messages([name, "Refreshing playlist metadata and restarting loop"])
return

elif current_input.casefold() == 'p' and hasattr(item, "path"): # print item path
Expand All @@ -456,7 +471,7 @@ def _match_item_to_input(self, name: str, item: MusifyItemSettable, current_inpu
current_input, kind=RemoteObjectType.TRACK, type_out=RemoteIDType.URI
)

self._log_padded([name, f"Updating URI: {item.uri} -> {uri}"], pad="<")
self.matcher.log_messages([name, f"Updating URI: {item.uri} -> {uri}"], pad="<")
item.uri = uri

self._switched.append(item)
Expand All @@ -468,3 +483,11 @@ def _match_item_to_input(self, name: str, item: MusifyItemSettable, current_inpu
current_input = ""

return current_input

def as_dict(self) -> dict[str, Any]:
return {
"matcher": self.matcher,
"remote_source": self.factory.api.source,
"interval": self.interval,
"allow_karaoke": self.allow_karaoke,
}
43 changes: 32 additions & 11 deletions musify/libraries/remote/core/processors/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Searches for matches on remote APIs, matches the item to the best matching result from the query,
and assigns the ID of the matched object back to the item.
"""
import logging
from collections.abc import Mapping, Sequence, Iterable, Collection
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
Expand All @@ -18,6 +19,8 @@
from musify.libraries.remote.core.enum import RemoteObjectType
from musify.libraries.remote.core.factory import RemoteObjectFactory
from musify.log import REPORT
from musify.log.logger import MusifyLogger
from musify.processors.base import Processor
from musify.processors.match import ItemMatcher
from musify.types import UnitIterable
from musify.utils import align_string, get_max_width
Expand Down Expand Up @@ -60,10 +63,12 @@ class SearchConfig:
allow_karaoke: bool = False


class RemoteItemSearcher(ItemMatcher):
class RemoteItemSearcher(Processor):
"""
Searches for remote matches for a list of item collections.
:param matcher: The :py:class:`ItemMatcher` to use when comparing any changes made by the user in remote playlists
during the checking operation
:param object_factory: The :py:class:`RemoteObjectFactory` to use when creating new remote objects.
This must have a :py:class:`RemoteAPI` assigned for this processor to work as expected.
:param use_cache: Use the cache when calling the API endpoint. Set as False to refresh the cached response.
Expand Down Expand Up @@ -99,9 +104,16 @@ def api(self) -> RemoteAPI:
"""The :py:class:`RemoteAPI` to call"""
return self.factory.api

def __init__(self, object_factory: RemoteObjectFactory, use_cache: bool = False):
def __init__(self, matcher: ItemMatcher, object_factory: RemoteObjectFactory, use_cache: bool = False):
super().__init__()

# noinspection PyTypeChecker
#: The :py:class:`MusifyLogger` for this object
self.logger: MusifyLogger = logging.getLogger(__name__)

#: The :py:class:`ItemMatcher` to use when comparing any changes made by the user in remote playlists
#: during the checking operation
self.matcher = matcher
#: The :py:class:`RemoteObjectFactory` to use when creating new remote objects.
self.factory = object_factory
#: When true, use the cache when calling the API endpoint
Expand All @@ -111,7 +123,7 @@ def _get_results(
self, item: MusifyObject, kind: RemoteObjectType, settings: SearchConfig
) -> list[dict[str, Any]] | None:
"""Query the API to get results for the current item based on algorithm settings"""
self.clean_tags(item)
self.matcher.clean_tags(item)

def execute_query(keys: Iterable[TagField]) -> tuple[list[dict[str, Any]], str]:
"""Generate and execute the query against the API for the given item's cleaned ``keys``"""
Expand All @@ -126,9 +138,9 @@ def execute_query(keys: Iterable[TagField]) -> tuple[list[dict[str, Any]], str]:
results, query = execute_query(settings.search_fields_3)

if results:
self._log_padded([item.name, f"Query: {query}", f"{len(results)} results"])
self.matcher.log_messages([item.name, f"Query: {query}", f"{len(results)} results"])
return results
self._log_padded([item.name, f"Query: {query}", "Match failed: No results."], pad="<")
self.matcher.log_messages([item.name, f"Query: {query}", "Match failed: No results."], pad="<")

def _log_results(self, results: Mapping[str, ItemSearchResult]) -> None:
"""Logs the final results of the ItemSearcher"""
Expand Down Expand Up @@ -217,18 +229,20 @@ def _search_collection[T: MusifyItemSettable](self, collection: MusifyCollection

skipped = tuple(item for item in collection if item.has_uri is not None)
if len(skipped) == len(collection):
self._log_padded([collection.name, "Skipping search, no items to search"], pad='<')
self.matcher.log_messages([collection.name, "Skipping search, no items to search"], pad='<')

if getattr(collection, "compilation", True) is False:
self._log_padded([collection.name, "Searching for collection as a unit"], pad='>')
self.matcher.log_messages([collection.name, "Searching for collection as a unit"], pad='>')
self._search_collection_unit(collection=collection)

missing = [item for item in collection.items if item.has_uri is None]
if missing:
self._log_padded([collection.name, f"Searching for {len(missing)} unmatched items in this {kind}"])
self.matcher.log_messages(
[collection.name, f"Searching for {len(missing)} unmatched items in this {kind}"]
)
self._search_items(collection=collection)
else:
self._log_padded([collection.name, "Searching for distinct items in collection"], pad='>')
self.matcher.log_messages([collection.name, "Searching for distinct items in collection"], pad='>')
self._search_items(collection=collection)

return ItemSearchResult(
Expand All @@ -248,7 +262,7 @@ def _get_item_match[T: MusifyItemSettable](
# noinspection PyTypeChecker
results: Iterable[T] = map(self.factory[kind], responses or ())

result = self.match(
result = self.matcher(
item,
results=results,
match_on=match_on if match_on is not None else search_config.match_fields,
Expand Down Expand Up @@ -288,7 +302,7 @@ def _search_collection_unit[T: MusifyItemSettable](self, collection: MusifyColle
# order to prioritise results that are closer to the item count of the input collection
results: list[T] = sorted(map(self.factory[kind], responses), key=lambda x: abs(x._total - len(collection)))

result = self.match(
result = self.matcher(
collection,
results=results,
match_on=search_config.match_fields,
Expand All @@ -309,3 +323,10 @@ def _search_collection_unit[T: MusifyItemSettable](self, collection: MusifyColle
for item, match in matches:
if match and match.has_uri:
item.uri = match.uri

def as_dict(self) -> dict[str, Any]:
return {
"matcher": self.matcher,
"remote_source": self.factory.api.source,
"interval": self.use_cache,
}
Loading

0 comments on commit 07b23af

Please sign in to comment.