Skip to content

Commit

Permalink
implement merge playlists functionality (#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
geo-martino authored Apr 3, 2024
1 parent 18af39c commit 5bba1ac
Show file tree
Hide file tree
Showing 17 changed files with 240 additions and 73 deletions.
2 changes: 2 additions & 0 deletions docs/release-history.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ Added
-----

* Add debug log for error failure reason when loading tracks
* :py:meth:`.MusifyCollection.intersection` and :py:meth:`.MusifyCollection.difference` methods
* :py:meth:`.Playlist.merge` and :py:meth:`.Library.merge_playlists` methods

Changed
-------
Expand Down
1 change: 1 addition & 0 deletions musify/api/authorise.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@


class APIAuthoriser:
# noinspection GrazieInspection
"""
Authorises and validates an API token for given input parameters.
Functions for returning formatted headers for future, authorised requests.
Expand Down
18 changes: 17 additions & 1 deletion musify/libraries/core/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,22 @@ def sort(
if reverse:
self.items.reverse()

def difference(self, other: Iterable[T]) -> list[T]:
"""
Return the difference between the items in this collection and an ``other`` collection as a new list.
(i.e. all items that are in this collection but not the ``other`` collection).
"""
return [item for item in other if item not in self]

def intersection(self, other: Iterable[T]) -> list[T]:
"""
Return the difference between the items in this collection and an ``other`` collection as a new list.
(i.e. all items that are in both this collection and the ``other`` collection).
"""
return [item for item in other if item in self]

@staticmethod
def _condense_attributes(attributes: dict[str, Any]) -> dict[str, Any]:
"""Condense the attributes of the given map for cleaner attribute displaying"""
Expand Down Expand Up @@ -312,7 +328,7 @@ def __getitem__(
f"Key is invalid. The following errors were thrown: {[str(ex) for ex in caught_exceptions]}"
)

def __get_item_getters(self, __key: str) -> list[ItemGetterStrategy]:
def __get_item_getters(self, __key: str | MusifyItem | File | RemoteResponse) -> list[ItemGetterStrategy]:
getters = []
if isinstance(__key, File):
getters.append(PathGetter(__key.path))
Expand Down
90 changes: 71 additions & 19 deletions musify/libraries/core/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
import datetime
import logging
from abc import ABCMeta, abstractmethod
from collections.abc import Collection, Mapping
from collections.abc import Collection, Mapping, Iterable
from copy import deepcopy
from typing import Any
from typing import Self

from musify.core.base import MusifyItem
from musify.exception import MusifyTypeError
Expand Down Expand Up @@ -242,31 +242,54 @@ def date_modified(self) -> datetime.datetime | None:
""":py:class:`datetime.datetime` object representing when the playlist was last modified"""
raise NotImplementedError

@abstractmethod
def merge(self, playlist: Playlist[T]) -> None:
def merge(self, other: Iterable[T], reference: Self | None = None) -> None:
"""
**WARNING: NOT IMPLEMENTED YET**
Merge tracks in this playlist with another playlist synchronising tracks between the two.
Merge tracks in this playlist with another collection, synchronising tracks between the two.
Only modifies this playlist.
Sort order is not preserved when merging.
Any items that need to be added to this playlist will be added at the end of the playlist.
Duplicates that are present in the ``other`` collection are filtered out by default.
:param other: The collection of items to merge onto this playlist.
:param reference: Optionally, provide a reference playlist to compare both the current playlist
and the ``other`` items to. The function will determine tracks to remove from
this playlist based on the reference. Useful for using this function as a synchronizer
where the reference refers to the playlist at the previous sync.
"""
# TODO: merge playlists adding/removing tracks as needed.
raise NotImplementedError
if not self._validate_item_type(other):
raise MusifyTypeError([type(i).__name__ for i in other])

def __or__(self, other: Playlist[T]):
if reference is None:
self.extend(self.difference(other), allow_duplicates=False)
return

for item in reference:
if item not in other and item in self:
self.remove(item)

self.extend(reference.difference(other), allow_duplicates=False)

def __or__(self, other: Playlist[T]) -> Self:
if not isinstance(other, self.__class__):
raise MusifyTypeError(
f"Incorrect item given. Cannot merge with {other.__class__.__name__} "
f"as it is not a {self.__class__.__name__}"
)
raise NotImplementedError

def __ior__(self, other: Playlist[T]):
self_copy = deepcopy(self)
self_copy.merge(other)
return self_copy

def __ior__(self, other: Playlist[T]) -> Self:
if not isinstance(other, self.__class__):
raise MusifyTypeError(
f"Incorrect item given. Cannot merge with {other.__class__.__name__} "
f"as it is not a {self.__class__.__name__}"
)
raise NotImplementedError

self.merge(other)
return self


class Library[T: Track](MusifyCollection[T], metaclass=ABCMeta):
Expand Down Expand Up @@ -393,15 +416,44 @@ def log_playlists(self) -> None:
"""Log stats on currently loaded playlists"""
raise NotImplementedError

@abstractmethod
def merge_playlists(self, playlists: Library[T] | Collection[Playlist[T]] | Mapping[Any, Playlist[T]]) -> None:
def merge_playlists(
self,
playlists: Library[T] | Collection[Playlist[T]] | Mapping[str, Playlist[T]],
reference: Library[T] | Collection[Playlist[T]] | Mapping[str, Playlist[T]] | None = None,
) -> None:
"""
**WARNING: NOT IMPLEMENTED YET**
Merge playlists from given list/map/library to this library
Merge playlists from given list/map/library to this library.
See :py:meth:`.Playlist.merge` for more info.
:param playlists: The playlists to merge onto this library's playlists.
If a given playlist is not found in this library, simply add the playlist to this library.
:param reference: Optionally, provide a reference playlist to compare both the current playlist
and the ``other`` items to. The function will determine tracks to remove from
this playlist based on the reference. Useful for using this function as a synchronizer
where the reference refers to the playlist at the previous sync.
"""
# TODO: merge playlists adding/removing tracks as needed.
# Most likely will need to implement some method on playlist class too
raise NotImplementedError
def get_playlists_map(
value: Library[T] | Collection[Playlist[T]] | Mapping[str, Playlist[T]]
) -> Mapping[str, Playlist[T]]:
"""Reformat the input playlist values to map"""
if isinstance(value, Mapping):
return value
elif isinstance(value, Library):
return value.playlists
elif isinstance(value, Collection):
return {pl.name: pl for pl in value}
raise MusifyTypeError(f"Unrecognised input type: {value.__class__.__name__}")

playlists = get_playlists_map(playlists)
reference = get_playlists_map(reference) if reference is not None else {}

for name, playlist in playlists.items():
if name not in self.playlists:
self.playlists[name] = playlist
continue

self.playlists[name].merge(playlist, reference=reference.get(name))


class Folder[T: Track](MusifyCollection[T], metaclass=ABCMeta):
Expand Down
7 changes: 1 addition & 6 deletions musify/libraries/local/library/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from musify.core.result import Result
from musify.exception import MusifyError
from musify.file.path_mapper import PathMapper, PathStemMapper
from musify.libraries.core.object import Playlist, Library
from musify.libraries.core.object import Library
from musify.libraries.local.collection import LocalCollection, LocalFolder, LocalAlbum, LocalArtist, LocalGenres
from musify.libraries.local.playlist import PLAYLIST_CLASSES, LocalPlaylist, load_playlist
from musify.libraries.local.track import TRACK_CLASSES, LocalTrack, load_track
Expand Down Expand Up @@ -384,11 +384,6 @@ def save_playlists(self, dry_run: bool = True) -> dict[str, Result]:
"""
return {name: pl.save(dry_run=dry_run) for name, pl in self.playlists.items()}

def merge_playlists(
self, playlists: Library[LocalTrack] | Collection[Playlist[LocalTrack]] | Mapping[Any, Playlist[LocalTrack]]
) -> None:
raise NotImplementedError

###########################################################################
## Backup/restore
###########################################################################
Expand Down
3 changes: 0 additions & 3 deletions musify/libraries/local/playlist/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,3 @@ def save(self, dry_run: bool = True, *args, **kwargs) -> Result:
:return: :py:class:`Result` object with stats on the changes to the playlist.
"""
raise NotImplementedError

def merge(self, playlist: Playlist[LocalTrack]) -> None:
raise NotImplementedError
4 changes: 3 additions & 1 deletion musify/libraries/local/playlist/xautopf.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,9 @@ def get_sorter(self) -> ItemSorter | None:
shuffle_weight = float(self.xml_smart_playlist.get("@ShuffleSameArtistWeight", 0))

return ItemSorter(fields=fields, shuffle_mode=shuffle_mode, shuffle_weight=shuffle_weight)
return ItemSorter(fields=fields or self.defined_sort[6]) # TODO: workaround - see cls.custom_sort

# TODO: remove defined_sort workaround here - see cls.custom_sort
return ItemSorter(fields=fields or next(iter(self.defined_sort.values())))

def parse_sorter(self, sorter: ItemSorter | None = None) -> None:
"""Update the loaded ``xml`` object by parsing the given ``sorter`` to its XML playlist representation."""
Expand Down
3 changes: 0 additions & 3 deletions musify/libraries/remote/core/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,6 @@ def _get_track_uris_from_api_response(self) -> list[str]:
"""
raise NotImplementedError

def merge(self, playlist: Playlist[T]) -> None:
raise NotImplementedError


class RemoteAlbum[T: RemoteTrack](Album[T], RemoteCollectionLoader[T], metaclass=ABCMeta):
"""Extracts key ``album`` data from a remote API JSON response."""
Expand Down
10 changes: 1 addition & 9 deletions musify/libraries/remote/spotify/library.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
"""
Implements a :py:class:`RemoteLibrary` for Spotify.
"""
from collections.abc import Collection, Mapping, Iterable
from collections.abc import Collection, Iterable
from typing import Any

from musify.libraries.core.object import Playlist, Library
from musify.libraries.remote.core.enum import RemoteObjectType
from musify.libraries.remote.core.library import RemoteLibrary
from musify.libraries.remote.core.object import RemoteTrack
from musify.libraries.remote.spotify.api import SpotifyAPI
from musify.libraries.remote.spotify.factory import SpotifyObjectFactory
from musify.libraries.remote.spotify.object import SpotifyTrack, SpotifyAlbum, SpotifyArtist, SpotifyPlaylist
Expand Down Expand Up @@ -145,9 +143,3 @@ def enrich_saved_artists(self, tracks: bool = False, types: Collection[str] = ()
album.refresh(skip_checks=False)

self.logger.debug(f"Enrich {self.api.source} artists: DONE\n")

def merge_playlists(
self,
playlists: Library[RemoteTrack] | Collection[Playlist[RemoteTrack]] | Mapping[Any, Playlist[RemoteTrack]]
) -> None:
raise NotImplementedError
3 changes: 2 additions & 1 deletion musify/processors/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,5 +265,6 @@ def as_dict(self):
return {
"condition": self.condition,
"expected": self.expected,
"field": self.field.name if self.field else None
"field": self.field.name if self.field else None,
"reference_required": self.reference_required,
}
13 changes: 8 additions & 5 deletions musify/processors/sort.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ def __init__(
):
super().__init__()
fields = to_collection(fields, list) if isinstance(fields, Field) else fields
self.sort_fields: Mapping[Field | None, bool] = {field: False for field in fields} \
self.sort_fields: dict[Field | None, bool] = {field: False for field in fields} \
if isinstance(fields, Sequence) else fields

self.shuffle_mode = shuffle_mode
Expand All @@ -138,15 +138,18 @@ def sort(self, items: MutableSequence[MusifyItem]) -> None:
items.extend(flatten_nested(items_nested))
elif self.shuffle_mode == ShuffleMode.RANDOM: # random
shuffle(items)
# TODO: implement below shuffle modes correctly, currently defaulting to random
elif self.shuffle_mode == ShuffleMode.HIGHER_RATING:
shuffle(items) # TODO: implement this shuffle mode correctly
shuffle(items)
elif self.shuffle_mode == ShuffleMode.RECENT_ADDED:
shuffle(items) # TODO: implement this shuffle mode correctly
shuffle(items)
elif self.shuffle_mode == ShuffleMode.DIFFERENT_ARTIST:
shuffle(items) # TODO: implement this shuffle mode correctly
shuffle(items)

@classmethod
def _sort_by_fields(cls, items_grouped: MutableMapping, fields: MutableMapping[Field, bool]) -> MutableMapping:
def _sort_by_fields(
cls, items_grouped: MutableMapping, fields: MutableMapping[Field | None, bool]
) -> MutableMapping:
"""
Sort items by the given fields recursively in the order given.
Expand Down
9 changes: 6 additions & 3 deletions scripts/pytest_repeat.bat
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
set COUNTER=0

echo "Executing %2 repetitions for tests: %1"
set COUNTER_MAX=%1
for /F "Tokens=1*" %%A in ("%*") do set "PYTEST_ARGS=%%B"

echo "Executing %COUNTER_MAX% repetitions for tests: %PYTEST_ARGS%"

:repeat
set /A COUNTER=COUNTER+1
echo "Executing repetition: %COUNTER%"

pytest %1 || goto :fail
pytest %PYTEST_ARGS% || goto :fail

if %COUNTER% == %2 (
if %COUNTER% == %COUNTER_MAX% (
goto :pass
) else (
goto :repeat
Expand Down
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def remove_file_handler(c: dict[str, Any]) -> None:

# This is a fork of the pytest-lazy-fixture package
# Fixes applied for issues with pytest >8.0: https://github.com/TvoroG/pytest-lazy-fixture/issues/65
# noinspection PyProtectedMember
@pytest.hookimpl(tryfirst=True)
def pytest_runtest_setup(item):
if hasattr(item, '_request'):
Expand All @@ -65,6 +66,7 @@ def pytest_runtest_setup(item):


def fillfixtures(_fillfixtures):
# noinspection PyProtectedMember
def fill(request):
item = request._pyfuncitem
fixturenames = getattr(item, "fixturenames", None)
Expand All @@ -82,28 +84,33 @@ def fill(request):
return fill


# noinspection PyUnusedLocal
@pytest.hookimpl(tryfirst=True)
def pytest_fixture_setup(fixturedef, request):
val = getattr(request, 'param', None)
if is_lazy_fixture(val):
request.param = request.getfixturevalue(val.name)


# noinspection PyProtectedMember
def pytest_runtest_call(item):
if hasattr(item, 'funcargs'):
for arg, val in item.funcargs.items():
if is_lazy_fixture(val):
item.funcargs[arg] = item._request.getfixturevalue(val.name)


# noinspection PyUnusedLocal
@pytest.hookimpl(hookwrapper=True)
def pytest_pycollect_makeitem(collector, name, obj):
# noinspection PyGlobalUndefined
global current_node
current_node = collector
yield
current_node = None


# noinspection PyUnusedLocal
def pytest_make_parametrize_id(config, val, argname):
if is_lazy_fixture(val):
return val.name
Expand All @@ -116,6 +123,7 @@ def pytest_generate_tests(metafunc):
normalize_metafunc_calls(metafunc)


# noinspection PyProtectedMember
def normalize_metafunc_calls(metafunc, used_keys=None):
newcalls = []
for callspec in metafunc._calls:
Expand All @@ -124,6 +132,7 @@ def normalize_metafunc_calls(metafunc, used_keys=None):
metafunc._calls = newcalls


# noinspection PyProtectedMember
def copy_metafunc(metafunc):
copied = copy.copy(metafunc)
copied.fixturenames = copy.copy(metafunc.fixturenames)
Expand All @@ -139,6 +148,7 @@ def copy_metafunc(metafunc):
return copied


# noinspection PyProtectedMember
def normalize_call(callspec, metafunc, used_keys):
fm = metafunc.config.pluginmanager.get_plugin('funcmanage')

Expand Down
Loading

0 comments on commit 5bba1ac

Please sign in to comment.