diff --git a/.flake8 b/.flake8 index 978aa54c..851862bd 100644 --- a/.flake8 +++ b/.flake8 @@ -6,3 +6,5 @@ per-file-ignores = docs/_howto/scripts/*:E402 tests/**/test_*.py:F811 max-line-length = 120 +# TODO: reduce this to 10 +max-complexity = 17 diff --git a/docs/contributing.rst b/docs/contributing.rst index 1e9a6767..445d7d4f 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -46,6 +46,16 @@ To run tests, you must have installed either the ``test`` or ``dev`` optional de # OR pytest path/to/test/file.py +As part of code validation, your code will be required to pass certain linting checks. +This project uses flake8 to help give an indication as to the quality of your code. +For the current checks that your code will be expected to pass, +please check the ``.flake8`` config file in the root of the project. + +To run these checks locally, simply run the following command in a terminal: + +.. code-block:: bash + + flake8 Submitting your changes for review ================================== diff --git a/docs/release-history.rst b/docs/release-history.rst index f326dc67..f7754900 100644 --- a/docs/release-history.rst +++ b/docs/release-history.rst @@ -42,6 +42,7 @@ Added * Property 'kind' to all objects which have an associated :py:class:`.RemoteObjectType` * Introduced :py:class:`.MusifyItemSettable` class to allow distinction between items that can have their properties set and those that can't +* Extend :py:class:`.FilterMatcher` with group_by tag functionality Changed ------- @@ -57,12 +58,24 @@ 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:`.ItemSorter` now shuffles randomly on unsupported types + + prioritises fields settings over shuffle settings Fixed ----- * :py:class:`.Comparer` dynamic processor methods which process string values now cast expected types before processing +Removed +------- + +* Redundant ShuffleBy enum and related arguments from :py:class:`.ItemSorter` + +Documentation +------------- + +* Added info on lint checking for the contributing page + 0.8.1 ===== diff --git a/musify/core/enum.py b/musify/core/enum.py index 232eda08..f2711dca 100644 --- a/musify/core/enum.py +++ b/musify/core/enum.py @@ -17,7 +17,7 @@ class MusifyEnum(IntEnum): @classmethod def map(cls, enum: Self) -> list[Self]: """ - "Optional mapper to apply to the enum found during :py:meth:`all`, :py:meth:`from_name`, + Optional mapper to apply to the enum found during :py:meth:`all`, :py:meth:`from_name`, and :py:meth:`from_value` calls """ return [enum] diff --git a/musify/field.py b/musify/field.py index ca63dbd2..3ef254e9 100644 --- a/musify/field.py +++ b/musify/field.py @@ -13,15 +13,6 @@ class TrackFieldMixin(TagField): # noinspection PyUnresolvedReferences @classmethod def map(cls, enum: Self) -> list[Self]: - """ - Mapper to apply to the enum found during :py:meth:`from_name` and :py:meth:`from_value` calls, - or from :py:meth:`to_tag` and :py:meth:`to_tags` calls - - Applies the following mapping: - * ``TRACK`` returns both ``TRACK_NUMBER`` and ``TRACK_TOTAL`` enums - * ``DISC`` returns both ``DISC_NUMBER`` and ``DISC_TOTAL`` enums - * all other enums return the enum in a unit list - """ if enum == cls.TRACK: return [cls.TRACK_NUMBER, cls.TRACK_TOTAL] elif enum == cls.DISC: diff --git a/musify/libraries/core/collection.py b/musify/libraries/core/collection.py index 2647a05c..648a117c 100644 --- a/musify/libraries/core/collection.py +++ b/musify/libraries/core/collection.py @@ -15,7 +15,7 @@ from musify.libraries.remote.core import RemoteResponse from musify.libraries.remote.core.base import RemoteObject from musify.libraries.remote.core.processors.wrangle import RemoteDataWrangler -from musify.processors.sort import ShuffleMode, ShuffleBy, ItemSorter +from musify.processors.sort import ShuffleMode, ItemSorter from musify.types import UnitSequence @@ -195,8 +195,7 @@ def clear(self) -> None: def sort( self, fields: UnitSequence[Field | None] | Mapping[Field | None, bool] = (), - shuffle_mode: ShuffleMode = ShuffleMode.NONE, - shuffle_by: ShuffleBy = ShuffleBy.TRACK, + shuffle_mode: ShuffleMode | None = None, shuffle_weight: float = 1.0, key: Field | None = None, reverse: bool = False, @@ -210,7 +209,6 @@ def sort( * List of tags/properties to sort by. * Map of `{: }`. If reversed is true, sort the ``tag/property`` in reverse. :param shuffle_mode: The mode to use for shuffling. - :param shuffle_by: The field to shuffle by when shuffling. :param shuffle_weight: The weights (between 0 and 1) to apply to shuffling modes that can use it. This value will automatically be limited to within the accepted range 0 and 1. :param key: Tag or property to sort on. Can be given instead of ``fields`` for a simple sort. @@ -221,9 +219,7 @@ def sort( if key is not None: ItemSorter.sort_by_field(self.items, field=key) else: - ItemSorter( - fields=fields, shuffle_mode=shuffle_mode, shuffle_by=shuffle_by, shuffle_weight=shuffle_weight - )(self.items) + ItemSorter(fields=fields, shuffle_mode=shuffle_mode, shuffle_weight=shuffle_weight)(self.items) if reverse: self.items.reverse() diff --git a/musify/libraries/core/object.py b/musify/libraries/core/object.py index 8e5e72c6..b3af8b48 100644 --- a/musify/libraries/core/object.py +++ b/musify/libraries/core/object.py @@ -29,7 +29,8 @@ class Track(MusifyItem, metaclass=ABCMeta): # noinspection PyPropertyDefinition @classmethod @property - def kind(cls): + def kind(cls) -> RemoteObjectType: + """The type of remote object associated with this class""" return RemoteObjectType.TRACK @property @@ -180,7 +181,8 @@ class Playlist[T: Track](MusifyCollection[T], metaclass=ABCMeta): # noinspection PyPropertyDefinition @classmethod @property - def kind(cls): + def kind(cls) -> RemoteObjectType: + """The type of remote object associated with this class""" return RemoteObjectType.PLAYLIST @property @@ -477,7 +479,8 @@ class Album[T: Track](MusifyCollection[T], metaclass=ABCMeta): # noinspection PyPropertyDefinition @classmethod @property - def kind(cls): + def kind(cls) -> RemoteObjectType: + """The type of remote object associated with this class""" return RemoteObjectType.ALBUM @property @@ -599,7 +602,8 @@ class Artist[T: Track](MusifyCollection[T], metaclass=ABCMeta): # noinspection PyPropertyDefinition @classmethod @property - def kind(cls): + def kind(cls) -> RemoteObjectType: + """The type of remote object associated with this class""" return RemoteObjectType.ARTIST @property diff --git a/musify/libraries/local/playlist/xautopf.py b/musify/libraries/local/playlist/xautopf.py index 7b3005dc..df0bd082 100644 --- a/musify/libraries/local/playlist/xautopf.py +++ b/musify/libraries/local/playlist/xautopf.py @@ -18,6 +18,7 @@ from musify.processors.filter_matcher import FilterMatcher from musify.processors.limit import ItemLimiter from musify.processors.sort import ItemSorter +from musify.utils import merge_maps @dataclass(frozen=True) @@ -169,18 +170,12 @@ def save(self, dry_run: bool = True, *_, **__) -> SyncResultXAutoPF: def _update_xml_paths(self, xml: dict[str, Any]) -> None: """Update the stored, parsed XML object with valid include and exclude paths""" - source = xml["SmartPlaylist"]["Source"] output = self.matcher.to_xml( items=self.tracks, original=self._original, path_mapper=lambda paths: self.path_mapper.unmap_many(paths, check_existence=False) ) - - # assign values to stored, parsed XML map - for k, v in output.items(): - source.pop(k, None) - if output.get(k): - source[k] = v + merge_maps(source=xml, new=output, extend=False, overwrite=True) def _update_comparers(self, xml: dict[str, Any]) -> None: """Update the stored, parsed XML object with appropriately formatted comparer settings""" diff --git a/musify/libraries/remote/core/response.py b/musify/libraries/remote/core/response.py index afc0652c..70716331 100644 --- a/musify/libraries/remote/core/response.py +++ b/musify/libraries/remote/core/response.py @@ -27,7 +27,7 @@ def id(self) -> str: @property @abstractmethod def kind(cls) -> RemoteObjectType: - """The type of remote object this python object represents""" + """The type of remote object this class represents""" raise NotImplementedError @abstractmethod diff --git a/musify/processors/filter_matcher.py b/musify/processors/filter_matcher.py index a15dbc1c..bd34d66e 100644 --- a/musify/processors/filter_matcher.py +++ b/musify/processors/filter_matcher.py @@ -9,7 +9,7 @@ from typing import Any from musify.core.base import MusifyItem -from musify.core.enum import Fields +from musify.core.enum import Fields, TagField, TagFields from musify.core.result import Result from musify.file.base import File from musify.file.path_mapper import PathMapper @@ -30,11 +30,13 @@ class MatchResult[T: Any](Result): excluded: Collection[T] = field(default=tuple()) #: Objects that matched :py:class:`Comparer` settings compared: Collection[T] = field(default=tuple()) + #: Objects that matched on any group_by settings + grouped: Collection[T] = field(default=tuple()) @property def combined(self) -> list[T]: """Combine the individual results to one combined list""" - return [track for results in [self.compared, self.included] for track in results if track not in self.excluded] + return [track for track in [*self.compared, *self.included, *self.grouped] if track not in self.excluded] class FilterMatcher[T: Any, U: Filter, V: Filter, X: FilterComparers](MusicBeeProcessor, FilterComposite[T]): @@ -44,11 +46,13 @@ class FilterMatcher[T: Any, U: Filter, V: Filter, X: FilterComparers](MusicBeePr :param include: A Filter for simple include comparisons to use when matching. :param exclude: A Filter for simple exclude comparisons to use when matching. :param comparers: A Filter for fine-grained comparisons to use when matching. - When not given or the given Filter if not ready, + When not given or the given Filter is not ready, returns all given values on match unless include or exclude are defined and ready. + :param group_by: Once all other filters are applied, also include all other items that match this tag type + from the matched items for any remaining unmatched items. """ - __slots__ = ("include", "exclude", "comparers") + __slots__ = ("include", "exclude", "comparers", "group_by") @classmethod def from_xml( @@ -100,7 +104,10 @@ def from_xml( filter_include.transform = lambda x: path_mapper.map(x, check_existence=False).casefold() filter_exclude.transform = lambda x: path_mapper.map(x, check_existence=False).casefold() - return cls(include=filter_include, exclude=filter_exclude, comparers=filter_compare) + group_by_value = cls._pascal_to_snake(xml["SmartPlaylist"]["@GroupBy"]) + group_by = None if group_by_value == "track" else TagFields.from_name(group_by_value)[0] + + return cls(include=filter_include, exclude=filter_exclude, comparers=filter_compare, group_by=group_by) def to_xml( self, @@ -123,44 +130,53 @@ def to_xml( ) return {} - output_path_map: Mapping[str, File] = {item.path.casefold(): item for item in items} + items_mapped: Mapping[str, File] = {item.path.casefold(): item for item in items} if self.comparers: # match again on current conditions to check for differences from original list - # this ensures that the paths included in the XML output - # do not include paths that match any of the conditions in the comparers + # which ensures that the paths included in the XML output + # do not include paths that match any of the comparer or group_by conditions # copy the list of tracks as the sorter will modify the list order original = original.copy() # get the last played track as reference in case comparer is looking for the playing tracks as reference ItemSorter.sort_by_field(original, field=Fields.LAST_PLAYED, reverse=True) - compared_path_map = { + matched_mapped = { item.path.casefold(): item for item in self.comparers(original, reference=original[0]) } if self.comparers.ready else {} + matched_mapped |= { + item.path.casefold(): item for item in self._get_group_by_results(original, matched_mapped.values()) + } - # get new include/exclude paths based on the leftovers after matching on comparers - self.exclude.values = list(compared_path_map.keys() - output_path_map) - self.include.values = [v for v in list(output_path_map - compared_path_map.keys()) if v not in self.exclude] + # get new include/exclude paths based on the leftovers after matching on comparers and group_by settings + self.exclude.values = list(matched_mapped.keys() - items_mapped) + self.include.values = [v for v in list(items_mapped - matched_mapped.keys()) if v not in self.exclude] else: - compared_path_map = output_path_map + matched_mapped = items_mapped - include_items = tuple(output_path_map[path] for path in self.include if path in output_path_map) - exclude_items = tuple(compared_path_map[path] for path in self.exclude if path in compared_path_map) + include_items = tuple(items_mapped[path] for path in self.include if path in items_mapped) + exclude_items = tuple(matched_mapped[path] for path in self.exclude if path in matched_mapped) - xml = {} + source = {} if len(include_items) > 0: # assign include paths to XML object - xml["ExceptionsInclude"] = "|".join(path_mapper(include_items)).replace("&", "&") + source["ExceptionsInclude"] = "|".join(path_mapper(include_items)).replace("&", "&") if len(exclude_items) > 0: # assign exclude paths to XML object - xml["Exceptions"] = "|".join(path_mapper(exclude_items)).replace("&", "&") + source["Exceptions"] = "|".join(path_mapper(exclude_items)).replace("&", "&") - return xml + return { + "SmartPlaylist": { + "@GroupBy": self.group_by.name.lower() if self.group_by else "track", + "Source": source, + } + } def __init__( self, include: U = FilterDefinedList(), exclude: V = FilterDefinedList(), comparers: X = FilterComparers(), + group_by: TagField | None = None, *_, **__ ): @@ -169,12 +185,15 @@ def __init__( #: The :py:class:`MusifyLogger` for this object self.logger: MusifyLogger = logging.getLogger(__name__) - #: The comparers to use when processing for this filter - self.comparers = comparers #: The filter that, when processed, returns items to include self.include = include #: The filter that, when processed, returns items to exclude self.exclude = exclude + #: The comparers to use when processing for this filter + self.comparers = comparers + #: Once all other filters are applied, also include all other items that match this tag type + #: from the matched items for the remaining items given + self.group_by = group_by def __call__(self, *args, **kwargs) -> list[T]: return self.process(*args, **kwargs) @@ -200,7 +219,29 @@ def process_to_result(self, values: Collection[T], reference: T | None = None, * tracks_reduced = {track for track in values if track not in included} compared = self.comparers(tracks_reduced, reference=reference) if self.comparers.ready else () - return MatchResult(included=included, excluded=excluded, compared=compared) + result = MatchResult(included=included, excluded=excluded, compared=compared) + grouped = self._get_group_by_results(values, matched=result.combined) + + if not grouped: + return result + return MatchResult(included=included, excluded=excluded, compared=compared, grouped=grouped) + + def _get_group_by_results(self, values: Collection[T], matched: Collection[T]) -> tuple[T, ...]: + if not self.group_by or len(values) == len(matched): + return () + tag_names = self.group_by.to_tag() + tag_values = {item[tag_name] for item in matched for tag_name in tag_names if hasattr(item, tag_name)} + + return tuple( + item for item in values + if item not in matched + and any(item[tag_name] in tag_values for tag_name in tag_names if hasattr(item, tag_name)) + ) def as_dict(self): - return {"include": self.include, "exclude": self.exclude, "comparers": self.comparers} + return { + "include": self.include, + "exclude": self.exclude, + "comparers": self.comparers, + "group_by": self.group_by.name.lower() if self.group_by else None + } diff --git a/musify/processors/sort.py b/musify/processors/sort.py index 3b69a485..8d088801 100644 --- a/musify/processors/sort.py +++ b/musify/processors/sort.py @@ -15,36 +15,30 @@ class ShuffleMode(MusifyEnum): - """Represents the possible shuffle modes to use when shuffling items in a playlist.""" - NONE = 0 - RANDOM = 1 - HIGHER_RATING = 2 - RECENT_ADDED = 3 + """Represents the possible shuffle modes to use when shuffling items using :py:class:`ItemSorter`.""" + RANDOM = 0 + HIGHER_RATING = 1 + RECENT_ADDED = 2 DIFFERENT_ARTIST = 3 -class ShuffleBy(MusifyEnum): - """Represents the possible items/properties to shuffle by when shuffling items in a playlist.""" - TRACK = 0 - ALBUM = 1 - ARTIST = 2 - - class ItemSorter(MusicBeeProcessor): """ Sort items in-place based on given conditions. - :param fields: - * When None and ShuffleMode is RANDOM, shuffle the items. Otherwise, do nothing. + :param fields: Fields to sort by. If defined, this value will always take priority over any shuffle settings + i.e. shuffle settings will be ignored. * List of tags/properties to sort by. * Map of ``{: }``. If reversed is true, sort the ``tag/property`` in reverse. - :param shuffle_mode: The mode to use for shuffling. - :param shuffle_by: The field to shuffle by when shuffling. - :param shuffle_weight: The weights (between -1 and 1) to apply to shuffling modes that can use it. - This value will automatically be limited to within the accepted range 0 and 1. + :param shuffle_mode: The mode to use for shuffling. Only used when no ``fields`` are given. + WARNING: Currently only ``RANDOM`` shuffle mode has been implemented. + Any other given value will default to ``RANDOM`` shuffling. + :param shuffle_weight: The weights (between -1 and 1) to apply to certain shuffling modes. + This value will automatically be limited to within the valid range -1 and 1. + Only used when no ``fields`` are given and shuffle_mode is not None or ``RANDOM``. """ - __slots__ = ("sort_fields", "shuffle_mode", "shuffle_by", "shuffle_weight") + __slots__ = ("sort_fields", "shuffle_mode", "shuffle_weight") #: Settings for custom sort codes. _custom_sort: dict[int, Mapping[Field, bool]] = { @@ -54,10 +48,9 @@ class ItemSorter(MusicBeeProcessor): Fields.TRACK_NUMBER: False, Fields.FILENAME: False } + # TODO: implement field_code 78 - manual order according to the order of tracks found + # in the MusicBee library file for a given playlist. } - # TODO: implement field_code 78 - manual order according to MusicBee library file. - # This is a workaround - _custom_sort[78] = _custom_sort[6] @classmethod def sort_by_field(cls, items: list[MusifyItem], field: Field | None = None, reverse: bool = False) -> None: @@ -131,7 +124,7 @@ def group(v: Any) -> None: @classmethod def from_xml(cls, xml: Mapping[str, Any], **__) -> Self: - fields: Sequence[Field] | Mapping[Field | bool] + fields: Sequence[Field] | Mapping[Field | bool] = () source = xml["SmartPlaylist"]["Source"] if "SortBy" in source: @@ -144,23 +137,23 @@ def from_xml(cls, xml: Mapping[str, Any], **__) -> Self: if field_code in cls._custom_sort: fields = cls._custom_sort[field_code] return cls(fields=fields) - else: + elif field_code != 78: field = Fields.from_value(field_code)[0] - if field is None: - return cls() - elif "SortBy" in source: - fields = {field: source["SortBy"]["@Order"] == "Descending"} - elif "DefinedSort" in source: - fields = [field] - else: - raise NotImplementedError("Sort type in XML not recognised") + if "SortBy" in source: + fields = {field: source["SortBy"]["@Order"] == "Descending"} + elif "DefinedSort" in source: + fields = [field] + else: + raise NotImplementedError("Sort type in XML not recognised") - shuffle_mode = ShuffleMode.from_name(cls._pascal_to_snake(xml["SmartPlaylist"]["@ShuffleMode"]))[0] - shuffle_by = ShuffleBy.from_name(cls._pascal_to_snake(xml["SmartPlaylist"]["@GroupBy"]))[0] - shuffle_weight = float(xml["SmartPlaylist"].get("@ShuffleSameArtistWeight", 1)) + shuffle_mode_value = cls._pascal_to_snake(xml["SmartPlaylist"]["@ShuffleMode"]) + if not fields and shuffle_mode_value != "none": + shuffle_mode = ShuffleMode.from_name(shuffle_mode_value)[0] + shuffle_weight = float(xml["SmartPlaylist"].get("@ShuffleSameArtistWeight", 0)) - return cls(fields=fields, shuffle_mode=shuffle_mode, shuffle_by=shuffle_by, shuffle_weight=shuffle_weight) + return cls(fields=fields, shuffle_mode=shuffle_mode, shuffle_weight=shuffle_weight) + return cls(fields=fields or cls._custom_sort[6]) # TODO: workaround - see cls._custom_sort def to_xml(self, **kwargs) -> Mapping[str, Any]: raise NotImplementedError @@ -168,18 +161,15 @@ def to_xml(self, **kwargs) -> Mapping[str, Any]: def __init__( self, fields: UnitSequence[Field | None] | Mapping[Field | None, bool] = (), - shuffle_mode: ShuffleMode = ShuffleMode.NONE, - shuffle_by: ShuffleBy = ShuffleBy.TRACK, - shuffle_weight: float = 1.0 + shuffle_mode: ShuffleMode | None = None, + shuffle_weight: float = 0.0 ): super().__init__() fields = to_collection(fields, list) if isinstance(fields, Field) else fields self.sort_fields: Mapping[Field | None, bool] self.sort_fields = {field: False for field in fields} if isinstance(fields, Sequence) else fields - self.shuffle_mode: ShuffleMode | None - self.shuffle_mode = shuffle_mode if shuffle_mode in [ShuffleMode.NONE, ShuffleMode.RANDOM] else ShuffleMode.NONE - self.shuffle_by: ShuffleBy | None = shuffle_by + self.shuffle_mode = shuffle_mode self.shuffle_weight = limit_value(shuffle_weight, floor=-1, ceil=1) def __call__(self, *args, **kwargs) -> None: @@ -190,17 +180,18 @@ def sort(self, items: MutableSequence[MusifyItem]) -> None: if len(items) == 0: return - if self.shuffle_mode == ShuffleMode.RANDOM: # random - shuffle(items) - elif self.shuffle_mode == ShuffleMode.NONE and self.sort_fields: # sort by fields + if self.sort_fields: items_nested = self._sort_by_fields({None: items}, fields=self.sort_fields) items.clear() items.extend(flatten_nested(items_nested)) - elif not self.sort_fields: # no sort - return - else: - # TODO: implement all shuffle modes - raise NotImplementedError(f"Shuffle mode not yet implemented: {self.shuffle_mode}") + elif self.shuffle_mode == ShuffleMode.RANDOM: # random + shuffle(items) + elif self.shuffle_mode == ShuffleMode.HIGHER_RATING: + shuffle(items) # TODO: implement this shuffle mode correctly + elif self.shuffle_mode == ShuffleMode.RECENT_ADDED: + shuffle(items) # TODO: implement this shuffle mode correctly + elif self.shuffle_mode == ShuffleMode.DIFFERENT_ARTIST: + shuffle(items) # TODO: implement this shuffle mode correctly @classmethod def _sort_by_fields(cls, items_grouped: MutableMapping, fields: MutableMapping[Field, bool]) -> MutableMapping: @@ -234,5 +225,5 @@ def as_dict(self): return { "sort_fields": fields, "shuffle_mode": self.shuffle_mode, - "shuffle_by": self.shuffle_by + "shuffle_weight": self.shuffle_weight } diff --git a/tests/__resources/playlist/Recently Added.xautopf b/tests/__resources/playlist/Recently Added.xautopf index 396920a3..00e20e93 100644 --- a/tests/__resources/playlist/Recently Added.xautopf +++ b/tests/__resources/playlist/Recently Added.xautopf @@ -1,5 +1,5 @@ - + diff --git a/tests/libraries/local/playlist/test_xautopf.py b/tests/libraries/local/playlist/test_xautopf.py index 9adc778d..f46765f2 100644 --- a/tests/libraries/local/playlist/test_xautopf.py +++ b/tests/libraries/local/playlist/test_xautopf.py @@ -40,7 +40,7 @@ def test_init_fails(self): with pytest.raises(InvalidFileType): XAutoPF(path=path_txt, tracks=tracks) - def test_load_playlist_1_settings(self, tracks: list[LocalTrack], path_mapper: PathMapper): + def test_load_playlist_bp_settings(self, tracks: list[LocalTrack], path_mapper: PathMapper): pl = XAutoPF(path=path_playlist_xautopf_bp, path_mapper=path_mapper) assert pl.name == splitext(basename(path_playlist_xautopf_bp))[0] @@ -49,7 +49,7 @@ def test_load_playlist_1_settings(self, tracks: list[LocalTrack], path_mapper: P assert pl.ext == splitext(basename(path_playlist_xautopf_bp))[1] assert not pl.tracks - # processor settings are tested in class-specific tests + # fine-grained processor settings are tested in class-specific tests assert pl.matcher.ready assert len(pl.matcher.comparers.comparers) == 3 assert not pl.limiter @@ -58,10 +58,10 @@ def test_load_playlist_1_settings(self, tracks: list[LocalTrack], path_mapper: P pl.load(tracks) assert [basename(track.path) for track in pl.tracks] == [basename(path_track_flac), basename(path_track_wma)] - def test_load_playlist_1_tracks(self, tracks: list[LocalTrack], path_mapper: PathMapper): + def test_load_playlist_bp_tracks(self, tracks: list[LocalTrack], path_mapper: PathMapper): # prepare tracks to search through tracks_actual = tracks - tracks = random_tracks(30) + tracks = random_tracks(50) for i, track in enumerate(tracks[10:40]): track.album = "an album" for i, track in enumerate(tracks[20:50]): @@ -74,11 +74,13 @@ def test_load_playlist_1_tracks(self, tracks: list[LocalTrack], path_mapper: Pat assert pl.tracks == tracks_actual[:2] pl = XAutoPF(path=path_playlist_xautopf_bp, tracks=tracks, path_mapper=path_mapper) - assert len(pl.tracks) == 11 - tracks_expected = tracks_actual[:2] + [track for track in tracks if 20 < track.track_number < 30] + assert len(pl.tracks) == 32 + tracks_expected = tracks_actual[:2] + [ + track for track in tracks if 20 < track.track_number < 30 or track.album == "an album" + ] assert pl.tracks == sorted(tracks_expected, key=lambda t: t.track_number) - def test_load_playlist_2_settings(self, path_mapper: PathMapper): + def test_load_playlist_ra_settings(self, path_mapper: PathMapper): pl = XAutoPF(path=path_playlist_xautopf_ra, tracks=random_tracks(20), path_mapper=path_mapper) assert pl.name == splitext(basename(path_playlist_xautopf_ra))[0] @@ -86,13 +88,13 @@ def test_load_playlist_2_settings(self, path_mapper: PathMapper): assert pl.path == path_playlist_xautopf_ra assert pl.ext == splitext(basename(path_playlist_xautopf_ra))[1] - # processor settings are tested in class-specific tests + # fine-grained processor settings are tested in class-specific tests assert not pl.matcher.ready assert not pl.matcher.comparers assert pl.limiter assert pl.sorter - def test_load_playlist_2_tracks(self, path_mapper: PathMapper): + def test_load_playlist_ra_tracks(self, path_mapper: PathMapper): # prepare tracks to search through tracks = random_tracks(50) for i, track in enumerate(tracks): @@ -109,7 +111,7 @@ def test_load_playlist_2_tracks(self, path_mapper: PathMapper): def test_save_playlist(self, tracks: list[LocalTrack], path: str, path_mapper: PathMapper, tmp_path: Path): # prepare tracks to search through tracks_actual = [track for track in tracks if track.path in [path_track_flac, path_track_wma]] - tracks = random_tracks(30) + tracks = random_tracks(50) for i, track in enumerate(tracks[10:40]): track.album = "an album" for i, track in enumerate(tracks[20:50]): @@ -121,7 +123,7 @@ def test_save_playlist(self, tracks: list[LocalTrack], path: str, path_mapper: P pl = XAutoPF(path=path, tracks=tracks, path_mapper=path_mapper) assert pl.path == path - assert len(pl.tracks) == 11 + assert len(pl.tracks) == 32 original_dt_modified = pl.date_modified original_dt_created = pl.date_created original_xml = deepcopy(pl.xml) @@ -137,7 +139,7 @@ def test_save_playlist(self, tracks: list[LocalTrack], path: str, path_mapper: P # first test results on a dry run result = pl.save(dry_run=True) - assert result.start == 11 + assert result.start == 32 assert result.start_description == "I am a description" assert result.start_included == 3 assert result.start_excluded == 3 @@ -162,7 +164,10 @@ def test_save_playlist(self, tracks: list[LocalTrack], path: str, path_mapper: P # TODO: these assertions always fail on GitHub actions but not locally, why? assert pl.date_modified > original_dt_modified assert pl.date_created == original_dt_created + assert pl.xml != original_xml + assert pl.xml["SmartPlaylist"]["@GroupBy"] == original_xml["SmartPlaylist"]["@GroupBy"] + assert pl.xml["SmartPlaylist"]["Source"]["Conditions"] == original_xml["SmartPlaylist"]["Source"]["Conditions"] # assert file has reported path count and paths in the file have been mapped to relative paths paths = pl.xml["SmartPlaylist"]["Source"]["ExceptionsInclude"].split("|") diff --git a/tests/processors/test_filter.py b/tests/processors/test_filter.py index 5a4a565b..2a7cf7dc 100644 --- a/tests/processors/test_filter.py +++ b/tests/processors/test_filter.py @@ -6,7 +6,7 @@ import pytest import xmltodict -from musify.core.enum import Fields +from musify.core.enum import Fields, TagFields from musify.file.path_mapper import PathStemMapper, PathMapper from musify.libraries.local.track import LocalTrack from musify.libraries.local.track.field import LocalTrackField @@ -293,6 +293,54 @@ def test_filter_on_all_comparers( matches = sorted(matcher(values=tracks), key=self.sort_key) assert matches == sorted(tracks_artist_reduced + tracks_include_reduced, key=self.sort_key) + def test_extend_result_on_group_by_album( + self, + tracks: list[LocalTrack], + tracks_include: list[LocalTrack], + tracks_album: list[LocalTrack], + path_mapper: PathMapper + ): + tracks_include = tracks_include.copy() + [tracks_album[0]] + matcher = FilterMatcher( + include=FilterDefinedList([track.path.casefold() for track in tracks_include]), + group_by=TagFields.ALBUM + ) + matcher.include.transform = lambda x: path_mapper.map(x, check_existence=False).casefold() + + result = matcher.process_to_result(values=tracks) + assert sorted(result.included, key=self.sort_key) == sorted(tracks_include, key=self.sort_key) + assert not result.excluded + assert not result.compared + assert result.grouped + assert sorted(result.grouped, key=self.sort_key) == sorted(tracks_album[1:], key=self.sort_key) + + combined_expected = sorted(tracks_include + tracks_album[1:], key=self.sort_key) + assert sorted(result.combined, key=self.sort_key) == combined_expected + + def test_extend_result_on_group_by_artist( + self, + tracks: list[LocalTrack], + tracks_include: list[LocalTrack], + tracks_artist: list[LocalTrack], + path_mapper: PathMapper + ): + tracks_include = tracks_include.copy() + [tracks_artist[0]] + matcher = FilterMatcher( + include=FilterDefinedList([track.path.casefold() for track in tracks_include]), + group_by=TagFields.ARTIST + ) + matcher.include.transform = lambda x: path_mapper.map(x, check_existence=False).casefold() + + result = matcher.process_to_result(values=tracks) + assert sorted(result.included, key=self.sort_key) == sorted(tracks_include, key=self.sort_key) + assert not result.excluded + assert not result.compared + assert result.grouped + assert sorted(result.grouped, key=self.sort_key) == sorted(tracks_artist[1:], key=self.sort_key) + + combined_expected = sorted(tracks_include + tracks_artist[1:], key=self.sort_key) + assert sorted(result.combined, key=self.sort_key) == combined_expected + ########################################################################### ## XML I/O ########################################################################### @@ -315,6 +363,8 @@ def test_from_xml_bp(self, path_mapper: PathStemMapper): assert matcher.comparers.match_all assert all(not m[1].ready for m in matcher.comparers.comparers.values()) + assert matcher.group_by == Fields.ALBUM + def test_from_xml_ra(self, path_mapper: PathStemMapper): with open(path_playlist_xautopf_ra, "r", encoding="utf-8") as f: xml = xmltodict.parse(f.read()) @@ -328,6 +378,8 @@ def test_from_xml_ra(self, path_mapper: PathStemMapper): assert not matcher.comparers.match_all assert all(not m[1].ready for m in matcher.comparers.comparers.values()) + assert matcher.group_by is None + def test_from_xml_cm(self, path_mapper: PathStemMapper): with open(path_playlist_xautopf_cm, "r", encoding="utf-8") as f: xml = xmltodict.parse(f.read()) @@ -384,6 +436,8 @@ def test_from_xml_cm(self, path_mapper: PathStemMapper): assert sub_comparers_2[1].condition == "in_the_last" assert sub_comparers_2[1].expected == ["7d"] + assert matcher.group_by == Fields.ALBUM + @pytest.mark.skip(reason="not implemented yet") def test_to_xml(self): pass diff --git a/tests/processors/test_sort.py b/tests/processors/test_sort.py index 92ed7b41..1f6e0b09 100644 --- a/tests/processors/test_sort.py +++ b/tests/processors/test_sort.py @@ -5,10 +5,11 @@ import pytest import xmltodict +from musify.core.enum import Fields from musify.field import TrackField from musify.libraries.local.track import LocalTrack from musify.libraries.local.track.field import LocalTrackField -from musify.processors.sort import ItemSorter, ShuffleMode, ShuffleBy +from musify.processors.sort import ItemSorter, ShuffleMode from musify.utils import strip_ignore_words from tests.core.printer import PrettyPrinterTester from tests.libraries.local.track.utils import random_tracks @@ -19,7 +20,7 @@ class TestItemSorter(PrettyPrinterTester): @pytest.fixture def obj(self) -> ItemSorter: - return ItemSorter(fields=[TrackField.ALBUM, TrackField.DISC, TrackField.TRACK], shuffle_mode=ShuffleMode.NONE) + return ItemSorter(fields=[TrackField.ALBUM, TrackField.DISC, TrackField.TRACK]) @pytest.fixture(scope="class") def tracks(self) -> list[LocalTrack]: @@ -77,12 +78,14 @@ def test_random_shuffle(self, tracks: list[LocalTrack]): assert tracks == tracks_original ItemSorter(shuffle_mode=ShuffleMode.RANDOM).sort(tracks) assert tracks != tracks_original + + # shuffle settings ignored when ``fields`` are defined ItemSorter(fields=TrackField.TITLE, shuffle_mode=ShuffleMode.RANDOM).sort(tracks) - assert tracks != sorted(tracks, key=lambda t: strip_ignore_words(t.title)) + assert tracks == sorted(tracks, key=lambda t: strip_ignore_words(t.title)) def test_multi_sort(self, tracks: list[LocalTrack]): tracks_sorted = sorted(tracks, key=lambda t: (t.album, t.disc_number, t.track_number)) - sorter = ItemSorter(fields=[TrackField.ALBUM, TrackField.DISC, TrackField.TRACK], shuffle_mode=ShuffleMode.NONE) + sorter = ItemSorter(fields=[TrackField.ALBUM, TrackField.DISC, TrackField.TRACK]) sorter(tracks) assert tracks == tracks_sorted @@ -97,7 +100,7 @@ def test_multi_sort(self, tracks: list[LocalTrack]): tracks_sorted.extend(list(group_3)) fields = {TrackField.ALBUM: True, TrackField.DISC: False, TrackField.TRACK: True} - sorter = ItemSorter(fields=fields, shuffle_mode=ShuffleMode.NONE) + sorter = ItemSorter(fields=fields) sorter(tracks) assert tracks == tracks_sorted @@ -107,21 +110,35 @@ def test_multi_sort(self, tracks: list[LocalTrack]): def test_from_xml_bp(self): with open(path_playlist_xautopf_bp, "r", encoding="utf-8") as f: xml = xmltodict.parse(f.read()) + + # shuffle settings not set as automatic order defined sorter = ItemSorter.from_xml(xml=xml) + assert sorter.sort_fields == {Fields.TRACK_NUMBER: False} + assert sorter.shuffle_mode is None + assert sorter.shuffle_weight == 0.0 - assert sorter.sort_fields == {LocalTrackField.TRACK_NUMBER: False} - assert sorter.shuffle_mode == ShuffleMode.NONE # switch to ShuffleMode.RECENT_ADDED once implemented - assert sorter.shuffle_by == ShuffleBy.ALBUM + # flip sorting to manual order to force function to set shuffle settings + xml["SmartPlaylist"]["Source"]["SortBy"]["@Field"] = "78" + sorter = ItemSorter.from_xml(xml=xml) + assert sorter.sort_fields == {} + assert sorter.shuffle_mode == ShuffleMode.RECENT_ADDED assert sorter.shuffle_weight == 0.5 def test_from_xml_ra(self): with open(path_playlist_xautopf_ra, "r", encoding="utf-8") as f: xml = xmltodict.parse(f.read()) + + # shuffle settings not set as automatic order defined sorter = ItemSorter.from_xml(xml=xml) + assert sorter.sort_fields == {Fields.DATE_ADDED: True} + assert sorter.shuffle_mode is None + assert sorter.shuffle_weight == 0.0 - assert sorter.sort_fields == {LocalTrackField.DATE_ADDED: True} - assert sorter.shuffle_mode == ShuffleMode.NONE - assert sorter.shuffle_by == ShuffleBy.TRACK + # flip sorting to manual order to force function to set shuffle settings + xml["SmartPlaylist"]["Source"]["SortBy"]["@Field"] = "78" + sorter = ItemSorter.from_xml(xml=xml) + assert sorter.sort_fields == {} + assert sorter.shuffle_mode == ShuffleMode.DIFFERENT_ARTIST assert sorter.shuffle_weight == -0.2 @pytest.mark.skip(reason="not implemented yet")