From e496e7c779bf8fe32711cd3f58b84efda61e4784 Mon Sep 17 00:00:00 2001 From: Jacob Pavlock Date: Thu, 27 Oct 2022 21:05:39 -0700 Subject: [PATCH] feat: new MetaAlbum and MetaTrack classes These classes are used to represent albums and tracks containing only metadata i.e. they do not exist in the filesystem or database. This is useful when describing an album or track from an online source e.g. musicbrainz. --- docs/developers/api/core.rst | 2 +- docs/developers/contributing.rst | 4 +- moe/library/album.py | 405 ++++++++++++------ moe/library/extra.py | 4 +- moe/library/lib_item.py | 118 ++--- moe/library/track.py | 300 +++++++------ moe/plugins/moe_import/import_cli.py | 17 +- moe/plugins/moe_import/import_core.py | 4 +- moe/plugins/musicbrainz/mb_core.py | 17 +- moe/util/core/match.py | 20 +- pyproject.toml | 2 +- tests/library/test_album.py | 254 ++++++++--- tests/library/test_track.py | 37 +- .../musicbrainz/resources/full_release.py | 26 +- tests/util/core/test_match.py | 4 - 15 files changed, 807 insertions(+), 407 deletions(-) diff --git a/docs/developers/api/core.rst b/docs/developers/api/core.rst index f1e27489..993c96f1 100644 --- a/docs/developers/api/core.rst +++ b/docs/developers/api/core.rst @@ -27,7 +27,7 @@ Library .. automodule:: moe.library :members: - :exclude-members: cache_ok, path, year, genre, original_year + :exclude-members: cache_ok, path, year, catalog_num, genre, original_year :show-inheritance: diff --git a/docs/developers/contributing.rst b/docs/developers/contributing.rst index 04edb6a1..fc1965a8 100644 --- a/docs/developers/contributing.rst +++ b/docs/developers/contributing.rst @@ -133,9 +133,9 @@ New Field Checklist If adding a new field to Moe, the following checklist can help ensure you cover all your bases: #. Add the database column to the appropriate library class (``Album``, ``Extra``, or ``Track``). - + * If the field represents metadata and does not deal with the filesystem, also add to the appropriate ``Meta`` class (``MetaAlbum`` or ``MetaTrack``). * If a multi-value field, add the non-plural equivalent property. See ``Track.genres`` and the accompanying single-value field, ``Track.genre`` for an example. - * Include documentation for the new field in the class docstring. + * Include documentation for the new field in the class docstring(s). #. Add to the item's ``fields`` method as necessary. #. Add code for reading the tag from a track file under ``Track.read_custom_tags``. diff --git a/moe/library/album.py b/moe/library/album.py index 3771068c..e16e6cdc 100644 --- a/moe/library/album.py +++ b/moe/library/album.py @@ -3,7 +3,7 @@ import datetime import logging from pathlib import Path, PurePath -from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast +from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast import pluggy import sqlalchemy as sa @@ -14,13 +14,13 @@ import moe from moe import config -from moe.library.lib_item import LibItem, LibraryError, PathType, SABase, SetType +from moe.library.lib_item import LibItem, LibraryError, MetaLibItem, SABase, SetType if TYPE_CHECKING: from moe.library.extra import Extra - from moe.library.track import Track + from moe.library.track import MetaTrack, Track -__all__ = ["Album", "AlbumError"] +__all__ = ["Album", "AlbumError", "MetaAlbum"] log = logging.getLogger("moe.album") @@ -74,54 +74,284 @@ class AlbumError(LibraryError): """Error performing some operation on an Album.""" +class MetaAlbum(MetaLibItem): + """A album containing only metadata. + + It does not exist on the filesystem nor in the library. It can be used + to represent information about a album to later be merged into a full ``Album`` + instance. + + There are no guarantees about information present in a ``MetaAlbum`` object i.e. + all attributes may be ``None``. + + Attributes: + artist (Optional[str]): AKA albumartist. + barcode (Optional[str]): UPC barcode. + catalog_num (Optional[str]): String of catalog numbers concatenated with ';'. + catalog_nums (Optional[set[str]]): Set of all catalog numbers. + country (Optional[str]): Country the album was released in + (two character identifier). + date (Optional[datetime.date]): Album release date. + disc_total (Optional[int]): Number of discs in the album. + label (Optional[str]): Album release label. + media (Optional[str]): Album release format (e.g. CD, Digital, etc.) + original_date (Optional[datetime.date]): Date of the original release of the + album. + title (Optional[str]) + track_total (Optional[int]): Number of tracks that *should* be in the album. + If an album is missing tracks, then ``len(tracks) < track_total``. + tracks (list[Track]): Album's corresponding tracks. + """ + + def __init__( + self, + artist: Optional[str] = None, + barcode: Optional[str] = None, + catalog_nums: Optional[set[str]] = None, + country: Optional[str] = None, + date: Optional[datetime.date] = None, + disc_total: Optional[int] = None, + label: Optional[str] = None, + media: Optional[str] = None, + original_date: Optional[datetime.date] = None, + title: Optional[str] = None, + track_total: Optional[int] = None, + tracks: Optional[list["MetaTrack"]] = None, + **kwargs, + ): + """Creates a MetaAlbum object with any additional custom fields as kwargs.""" + self._custom_fields = self._get_default_custom_fields() + self._custom_fields_set = set(self._custom_fields) + + self.artist = artist + self.barcode = barcode + self.catalog_nums = catalog_nums + self.country = country + self.date = date + self.disc_total = disc_total + self.label = label + self.media = media + self.original_date = original_date + self.title = title + self.track_total = track_total + + if not tracks: + self.tracks = [] + + for key, value in kwargs.items(): + setattr(self, key, value) + + if config.CONFIG.settings.original_date and self.original_date: + self.date = self.original_date + + log.debug(f"MetaAlbum created. [album={self!r}]") + + @property + def catalog_num(self) -> Optional[str]: + """Returns a string of all catalog_nums concatenated with ';'.""" + if self.catalog_nums is None: + return None + + return ";".join(self.catalog_nums) + + @catalog_num.setter + def catalog_num(self, catalog_num_str: Optional[str]): + """Sets a track's catalog_num from a string. + + Args: + catalog_num_str: For more than one catalog_num, they should be split with + ';'. + """ + if catalog_num_str is None: + self.catalog_nums = None + else: + self.catalog_nums = { + catalog_num.strip() for catalog_num in catalog_num_str.split(";") + } + + @property + def fields(self) -> set[str]: + """Returns any editable album fields.""" + return { + "artist", + "barcode", + "catalog_nums", + "country", + "date", + "disc_total", + "label", + "media", + "original_date", + "title", + "track_total", + }.union(self._custom_fields) + + def get_track(self, track_num: int, disc: int = 1) -> Optional["MetaTrack"]: + """Gets a MetaTrack by its track number.""" + return next( + ( + track + for track in self.tracks + if track.track_num == track_num and track.disc == disc + ), + None, + ) + + def merge(self, other: "MetaAlbum", overwrite: bool = False) -> None: + """Merges another album into this one. + + Args: + other: Other album to be merged with the current album. + overwrite: Whether or not to overwrite self if a conflict exists. + """ + log.debug(f"Merging MetaAlbums. [album_a={self!r}, album_b={other!r}") + + new_tracks: list["MetaTrack"] = [] + for other_track in other.tracks: + conflict_track = None + if other_track.track_num and other_track.disc: + conflict_track = self.get_track(other_track.track_num, other_track.disc) + if conflict_track: + conflict_track.merge(other_track, overwrite) + else: + new_tracks.append(other_track) + self.tracks.extend(new_tracks) + + for field in self.fields: + other_value = getattr(other, field) + self_value = getattr(self, field) + if other_value and (overwrite or (not overwrite and not self_value)): + setattr(self, field, other_value) + + log.debug( + f"MetaAlbums merged. [album_a={self!r}, album_b={other!r}, {overwrite=!r}]" + ) + + def __eq__(self, other: "MetaAlbum") -> bool: + """Compares MetaAlbums by their fields.""" + if type(self) != type(other): + return False + + for field in self.fields: + if not hasattr(other, field) or ( + getattr(self, field) != getattr(other, field) + ): + return False + + return True + + def __lt__(self, other: "MetaAlbum") -> bool: + """Sort an album based on its title, then artist, then date.""" + if self.title == other.title: + if self.artist == other.artist: + if self.date is None: + return False + if other.date is None: + return True + return self.date < other.date + + if self.artist is None: + return False + if other.artist is None: + return True + return self.artist < other.artist + + if self.title is None: + return False + if other.title is None: + return True + return self.title < other.title + + def __str__(self): + """String representation of an Album.""" + album_str = f"{self.artist} - {self.title}" + + if self.date: + album_str += f" ({self.date.year})" + + return album_str + + def __repr__(self): + """Represents an Album using its fields.""" + field_reprs = [] + for field in self.fields: + if hasattr(self, field): + field_reprs.append(f"{field}={getattr(self, field)!r}") + repr_str = "AlbumInfo(" + ", ".join(field_reprs) + + custom_field_reprs = [] + for custom_field, value in self._custom_fields.items(): + custom_field_reprs.append(f"{custom_field}={value}") + if custom_field_reprs: + repr_str += ", custom_fields=[" + ", ".join(custom_field_reprs) + "]" + + track_reprs = [] + for track in sorted(self.tracks): + track_reprs.append(f"{track.disc}.{track.track_num} - {track.title}") + repr_str += ", tracks=[" + ", ".join(track_reprs) + "]" + + repr_str += ")" + return repr_str + + def _get_default_custom_fields(self) -> dict[str, Any]: + """Returns the default custom album fields.""" + return { + field: default_val + for plugin_fields in config.CONFIG.pm.hook.create_custom_album_fields() + for field, default_val in plugin_fields.items() + } + + # Album generic, used for typing classmethod A = TypeVar("A", bound="Album") -class Album(LibItem, SABase): +class Album(LibItem, SABase, MetaAlbum): """An album is a collection of tracks and represents a specific album release. Albums also house any attributes that are shared by tracks e.g. albumartist. Attributes: artist (str): AKA albumartist. - barcode (str): UPC barcode. - catalog_nums (set[str]): Set of all catalog numbers. - country (str): Country the album was released in (two character identifier). + barcode (Optional[str]): UPC barcode. + catalog_nums (Optional[set[str]]): Set of all catalog numbers. + country (Optional[str]): Country the album was released in + (two character identifier). date (datetime.date): Album release date. disc_total (int): Number of discs in the album. extras (list[Extra]): Extra non-track files associated with the album. - label (str): Album release label. - media (str): Album release format (e.g. CD, Digital, etc.) - original_date (datetime.date): Date of the original release of the album. - original_year (int): Album original release year. Note, this field is read-only. - Set ``original_date`` instead. + label (Optional[str]): Album release label. + media (Optional[str]): Album release format (e.g. CD, Digital, etc.) + original_date (Optional[datetime.date]): Date of the original release of the + album. + original_year (Optional[int]): Album original release year. Note, this field is + read-only, set ``original_date`` instead. path (pathlib.Path): Filesystem path of the album directory. title (str) - track_total (int): Number of tracks that *should* be in the album. If an album - is missing tracks, then ``len(tracks) < track_total``. + track_total (Optional[int]): Number of tracks that *should* be in the album. + If an album is missing tracks, then ``len(tracks) < track_total``. tracks (list[Track]): Album's corresponding tracks. - year (int): Album release year. Note, this field is read-only. Set ``date`` + year (int): Album release year. Note, this field is read-only, set ``date`` instead. """ __tablename__ = "album" - _id: int = cast(int, Column(Integer, primary_key=True)) artist: str = cast(str, Column(String, nullable=False)) - barcode: str = cast(str, Column(String, nullable=True)) + barcode: Optional[str] = cast(Optional[str], Column(String, nullable=True)) catalog_nums: Optional[set[str]] = cast( Optional[set[str]], MutableSet.as_mutable(Column(SetType, nullable=True)) ) - country: str = cast(str, Column(String, nullable=True)) + country: Optional[str] = cast(Optional[str], Column(String, nullable=True)) date: datetime.date = cast(datetime.date, Column(Date, nullable=False)) disc_total: int = cast(int, Column(Integer, nullable=False, default=1)) - label: str = cast(str, Column(String, nullable=True)) - media: str = cast(str, Column(String, nullable=True)) - original_date: datetime.date = cast(datetime.date, Column(Date, nullable=True)) - path: Path = cast(Path, Column(PathType, nullable=False, unique=True)) + label: Optional[str] = cast(Optional[str], Column(String, nullable=True)) + media: Optional[str] = cast(Optional[str], Column(String, nullable=True)) + original_date: Optional[datetime.date] = cast( + Optional[datetime.date], Column(Date, nullable=True) + ) title: str = cast(str, Column(String, nullable=False)) - track_total: int = cast(int, Column(Integer, nullable=True)) + track_total: Optional[int] = cast(Optional[int], Column(Integer, nullable=True)) _custom_fields: dict[str, Any] = cast( dict[str, Any], Column( @@ -223,46 +453,8 @@ def from_dir(cls: type[A], album_path: Path) -> A: @property def fields(self) -> set[str]: - """Returns any editable album fields.""" - return { - "artist", - "barcode", - "catalog_nums", - "country", - "date", - "disc_total", - "extras", - "label", - "media", - "original_date", - "path", - "title", - "track_total", - "tracks", - }.union(self._custom_fields) - - @property - def catalog_num(self) -> Optional[str]: - """Returns a string of all catalog_nums concatenated with ';'.""" - if self.catalog_nums is None: - return None - - return ";".join(self.catalog_nums) - - @catalog_num.setter - def catalog_num(self, catalog_num_str: Optional[str]): - """Sets a track's catalog_num from a string. - - Args: - catalog_num_str: For more than one catalog_num, they should be split with - ';'. - """ - if catalog_num_str is None: - self.catalog_nums = None - else: - self.catalog_nums = { - catalog_num.strip() for catalog_num in catalog_num_str.split(";") - } + """Returns any editable, track-specific fields.""" + return super().fields.union({"path"}) def get_extra(self, rel_path: PurePath) -> Optional["Extra"]: """Gets an Extra by its path.""" @@ -272,14 +464,7 @@ def get_extra(self, rel_path: PurePath) -> Optional["Extra"]: def get_track(self, track_num: int, disc: int = 1) -> Optional["Track"]: """Gets a Track by its track number.""" - return next( - ( - track - for track in self.tracks - if track.track_num == track_num and track.disc == disc - ), - None, - ) + return cast("Track", super().get_track(track_num, disc)) def is_unique(self, other: "Album") -> bool: """Returns whether an album is unique in the library from ``other``.""" @@ -294,7 +479,7 @@ def is_unique(self, other: "Album") -> bool: return True - def merge(self, other: "Album", overwrite: bool = False) -> None: + def merge(self, other: Union["Album", MetaAlbum], overwrite: bool = False) -> None: """Merges another album into this one. Args: @@ -305,26 +490,28 @@ def merge(self, other: "Album", overwrite: bool = False) -> None: new_tracks: list["Track"] = [] for other_track in other.tracks: - conflict_track = self.get_track(other_track.track_num, other_track.disc) + conflict_track = None + if other_track.track_num and other_track.disc: + conflict_track = self.get_track(other_track.track_num, other_track.disc) if conflict_track: conflict_track.merge(other_track, overwrite) else: new_tracks.append(other_track) self.tracks.extend(new_tracks) - new_extras: list["Extra"] = [] - for other_extra in other.extras: - conflict_extra = self.get_extra(other_extra.rel_path) - if conflict_extra: - conflict_extra.merge(other_extra, overwrite) - else: - new_extras.append(other_extra) - self.extras.extend(new_extras) - - omit_fields = {"extras", "tracks"} - for field in self.fields - omit_fields: - other_value = getattr(other, field) - self_value = getattr(self, field) + if isinstance(other, Album): + new_extras: list["Extra"] = [] + for other_extra in other.extras: + conflict_extra = self.get_extra(other_extra.rel_path) + if conflict_extra: + conflict_extra.merge(other_extra, overwrite) + else: + new_extras.append(other_extra) + self.extras.extend(new_extras) + + for field in self.fields: + other_value = getattr(other, field, None) + self_value = getattr(self, field, None) if other_value and (overwrite or (not overwrite and not self_value)): setattr(self, field, other_value) @@ -333,8 +520,11 @@ def merge(self, other: "Album", overwrite: bool = False) -> None: ) @hybrid_property - def original_year(self) -> int: # type: ignore + def original_year(self) -> Optional[int]: # type: ignore """Gets an Album's year.""" + if self.original_date is None: + return None + return self.original_date.year @original_year.expression # type: ignore @@ -352,35 +542,10 @@ def year(cls): # noqa: B902 """Returns a year at the sql level.""" return sa.extract("year", cls.date) - def __eq__(self, other) -> bool: - """Compares Albums by their fields.""" - if not isinstance(other, Album): - return False - - omit_fields = {"extras", "tracks"} - for field in self.fields - omit_fields: - if not hasattr(other, field) or ( - getattr(self, field) != getattr(other, field) - ): - return False - - return True - - def __lt__(self, other: "Album") -> bool: - """Sort an album based on its title, then artist, then date.""" - if self.title == other.title: - if self.artist == other.artist: - return self.date < other.date - - return self.artist < other.artist - - return self.title < other.title - def __repr__(self): """Represents an Album using its fields.""" field_reprs = [] - omit_fields = {"tracks", "extras"} - for field in self.fields - omit_fields: + for field in self.fields: if hasattr(self, field): field_reprs.append(f"{field}={getattr(self, field)!r}") repr_str = "Album(" + ", ".join(field_reprs) @@ -403,15 +568,3 @@ def __repr__(self): repr_str += ")" return repr_str - - def __str__(self): - """String representation of an Album.""" - return f"{self.artist} - {self.title} ({self.year})" - - def _get_default_custom_fields(self) -> dict[str, Any]: - """Returns the default custom album fields.""" - return { - field: default_val - for plugin_fields in config.CONFIG.pm.hook.create_custom_album_fields() - for field, default_val in plugin_fields.items() - } diff --git a/moe/library/extra.py b/moe/library/extra.py index bb55fcc7..4cc713d8 100644 --- a/moe/library/extra.py +++ b/moe/library/extra.py @@ -13,7 +13,7 @@ import moe from moe import config from moe.library.album import Album -from moe.library.lib_item import LibItem, PathType, SABase +from moe.library.lib_item import LibItem, SABase __all__ = ["Extra"] @@ -75,8 +75,6 @@ class Extra(LibItem, SABase): __tablename__ = "extra" - _id: int = cast(int, Column(Integer, primary_key=True)) - path: Path = cast(Path, Column(PathType, nullable=False, unique=True)) _custom_fields: dict[str, Any] = cast( dict[str, Any], Column( diff --git a/moe/library/lib_item.py b/moe/library/lib_item.py index 343745bf..00b5e322 100644 --- a/moe/library/lib_item.py +++ b/moe/library/lib_item.py @@ -2,19 +2,20 @@ import logging from pathlib import Path -from typing import Any +from typing import Any, cast import pluggy import sqlalchemy import sqlalchemy as sa import sqlalchemy.event import sqlalchemy.orm +from sqlalchemy import Column, Integer from sqlalchemy.orm import declarative_base import moe from moe import config -__all__ = ["LibItem", "LibraryError"] +__all__ = ["LibItem", "LibraryError", "MetaLibItem"] log = logging.getLogger("moe.lib_item") @@ -205,60 +206,6 @@ def _process_after_flush( log.debug(f"Processed removed items. [{removed_items=!r}]") -class LibItem: - """Base class for library items i.e. Albums, Extras, and Tracks.""" - - _custom_fields = {} - _custom_fields_set = None - - @property - def path(self) -> Path: - """A library item's filesystem path.""" - raise NotImplementedError - - @property - def custom_fields(self) -> set[str]: - """Returns the custom fields of an item.""" - if self._custom_fields_set is None: - object.__setattr__( - self, "_custom_fields_set", set(self._get_default_custom_fields()) - ) - - assert self._custom_fields_set is not None - return self._custom_fields_set - - def _get_default_custom_fields(self) -> dict[str, Any]: - """Returns the default custom fields of an item.""" - raise NotImplementedError - - @property - def fields(self) -> set[str]: - """Returns the editable fields of an item.""" - raise NotImplementedError - - def is_unique(self, other: "LibItem") -> bool: - """Returns whether an item is unique in the library from ``other``.""" - raise NotImplementedError - - def __getattr__(self, name: str): - """See if ``name`` is a custom field.""" - if name in self.custom_fields: - return self._custom_fields[name] - else: - raise AttributeError from None - - def __setattr__(self, name, value): - """Set custom custom_fields if a valid key.""" - if name in self.custom_fields: - self._custom_fields[name] = value - else: - super().__setattr__(name, value) - - def __lt__(self, other): - """Library items implement the `lt` magic method to allow sorting.""" - raise NotImplementedError - - class PathType(sa.types.TypeDecorator): """A custom type for paths for database storage. @@ -313,3 +260,62 @@ def process_result_value(self, json_list, dialect): if json_list is not None: return set(json_list) return None + + +class MetaLibItem: + """Base class for MetaTrack and MetaAlbum objects representing metadata-only. + + These objects do not exist on the filesystem nor in the library. + """ + + _custom_fields = {} + _custom_fields_set = None + + @property + def custom_fields(self) -> set[str]: + """Returns the custom fields of an item.""" + if self._custom_fields_set is None: + object.__setattr__( + self, "_custom_fields_set", set(self._get_default_custom_fields()) + ) + + assert self._custom_fields_set is not None + return self._custom_fields_set + + def _get_default_custom_fields(self) -> dict[str, Any]: + """Returns the default custom fields of an item.""" + raise NotImplementedError + + @property + def fields(self) -> set[str]: + """Returns the editable fields of an item.""" + raise NotImplementedError + + def __getattr__(self, name: str): + """See if ``name`` is a custom field.""" + if name in self.custom_fields: + return self._custom_fields[name] + else: + raise AttributeError from None + + def __setattr__(self, name, value): + """Set custom custom_fields if a valid key.""" + if name in self.custom_fields: + self._custom_fields[name] = value + else: + super().__setattr__(name, value) + + def __lt__(self, other): + """Library items implement the `lt` magic method to allow sorting.""" + raise NotImplementedError + + +class LibItem(MetaLibItem): + """Base class for library items i.e. Albums, Extras, and Tracks.""" + + _id: int = cast(int, Column(Integer, primary_key=True)) + path: Path = cast(Path, Column(PathType, nullable=False, unique=True)) + + def is_unique(self, other: "LibItem") -> bool: + """Returns whether an item is unique in the library from ``other``.""" + raise NotImplementedError diff --git a/moe/library/track.py b/moe/library/track.py index 4426c1ca..ca2eeda6 100644 --- a/moe/library/track.py +++ b/moe/library/track.py @@ -14,10 +14,10 @@ import moe from moe import config -from moe.library.album import Album -from moe.library.lib_item import LibItem, LibraryError, PathType, SABase, SetType +from moe.library.album import Album, MetaAlbum +from moe.library.lib_item import LibItem, LibraryError, MetaLibItem, SABase, SetType -__all__ = ["Track", "TrackError"] +__all__ = ["MetaTrack", "Track", "TrackError"] log = logging.getLogger("moe.track") @@ -144,20 +144,185 @@ class TrackError(LibraryError): T = TypeVar("T", bound="Track") -class Track(LibItem, SABase): - """A single track. +class MetaTrack(MetaLibItem): + """A track containing only metadata. + + It does not exist on the filesystem nor in the library. It can be used + to represent information about a track to later be merged into a full ``Track`` + instance. + + Attributes: + album_obj (Optional[Album]): Corresponding Album object. + artist (Optional[str]) + artists (Optional[set[str]]): Set of all artists. + disc (Optional[int]): Disc number the track is on. + genre (Optional[str]): String of all genres concatenated with ';'. + genres (Optional[set[str]]): Set of all genres. + title (Optional[str]) + track_num (Optional[int]) + """ + + def __init__( + self, + album: MetaAlbum, + track_num: int, + artist: Optional[str] = None, + artists: Optional[set[str]] = None, + disc: int = 1, + genres: Optional[set[str]] = None, + title: Optional[str] = None, + **kwargs, + ): + """Creates a MetaTrack object with any additional custom fields as kwargs.""" + self._custom_fields = self._get_default_custom_fields() + self._custom_fields_set = set(self._custom_fields) + + self.album_obj = album + album.tracks.append(self) + + self.track_num = track_num + self.artist = artist + self.artists = artists + self.disc = disc + self.genres = genres + self.title = title + + # set default values + self.artist = self.album_obj.artist + + for key, value in kwargs.items(): + setattr(self, key, value) + + log.debug(f"MetaTrack created. [track={self!r}]") + + @property + def genre(self) -> Optional[str]: + """Returns a string of all genres concatenated with ';'.""" + if self.genres is None: + return None + + return ";".join(self.genres) + + @genre.setter + def genre(self, genre_str: Optional[str]): + """Sets a track's genre from a string. + + Args: + genre_str: For more than one genre, they should be split with ';'. + """ + if genre_str is None: + self.genres = None + else: + self.genres = {genre.strip() for genre in genre_str.split(";")} + + @property + def fields(self) -> set[str]: + """Returns any editable, track-specific fields.""" + return { + "album_obj", + "artist", + "artists", + "disc", + "genres", + "title", + "track_num", + }.union(set(self._custom_fields)) + + def merge(self, other: "MetaTrack", overwrite: bool = False): + """Merges another track into this one. + + Args: + other: Other track to be merged with the current track. + overwrite: Whether or not to overwrite self if a conflict exists. + """ + log.debug( + f"Merging tracks. [track_a={self!r}, track_b={other!r}, {overwrite=!r}]" + ) + + omit_fields = {"album_obj"} + for field in self.fields - omit_fields: + other_value = getattr(other, field, None) + self_value = getattr(self, field, None) + if other_value and (overwrite or (not overwrite and not self_value)): + setattr(self, field, other_value) + + log.debug( + f"Tracks merged. [track_a={self!r}, track_b={other!r}, {overwrite=!r}]" + ) + + def __eq__(self, other) -> bool: + """Compares Tracks by their fields.""" + if type(self) != type(other): + return False + + for field in self.fields: + if not hasattr(other, field) or ( + getattr(self, field) != getattr(other, field) + ): + return False + + return True + + def __lt__(self, other) -> bool: + """Sort based on album, then disc, then track number.""" + if self.album_obj == other.album_obj: + if self.disc == other.disc: + return self.track_num < other.track_num + + return self.disc < other.disc + + return self.album_obj < other.album_obj + + def __repr__(self): + """Represents a Track using track-specific and relevant album fields.""" + field_reprs = [] + omit_fields = {"album_obj"} + for field in self.fields - omit_fields: + if hasattr(self, field): + field_reprs.append(f"{field}={getattr(self, field)!r}") + repr_str = ( + f"{__class__.__name__}(" + + ", ".join(field_reprs) + + f", album='{self.album_obj}'" + ) + + custom_field_reprs = [] + for custom_field, value in self._custom_fields.items(): + custom_field_reprs.append(f"{custom_field}={value}") + if custom_field_reprs: + repr_str += ", custom_fields=[" + ", ".join(custom_field_reprs) + "]" + + repr_str += ")" + return repr_str + + def __str__(self): + """String representation of a track.""" + return f"{self.artist} - {self.title}" + + def _get_default_custom_fields(self) -> dict[str, Any]: + """Returns the default custom track fields.""" + return { + field: default_val + for plugin_fields in config.CONFIG.pm.hook.create_custom_track_fields() + for field, default_val in plugin_fields.items() + } + + +class Track(LibItem, SABase, MetaTrack): + """A single track in the library. Attributes: album (str) albumartist (str) album_obj (Album): Corresponding Album object. artist (str) - artists (set[str]): Set of all artists. - audio_format (str): File audio format. One of ['aac', 'aiff', 'alac', 'ape', - 'asf', 'dsf', 'flac', 'ogg', 'opus', 'mp3', 'mpc', 'wav', 'wv'] + artists (Optional[set[str]]): Set of all artists. + audio_format (Optional[str]): File audio format. + One of ['aac', 'aiff', 'alac', 'ape', 'asf', 'dsf', 'flac', 'ogg', 'opus', + 'mp3', 'mpc', 'wav', 'wv'] disc (int): Disc number the track is on. genre (str): String of all genres concatenated with ';'. - genres (set[str]): Set of all genres. + genres (Optional[set[str]]): Set of all genres. path (Path): Filesystem path of the track file. title (str) track_num (int) @@ -169,17 +334,15 @@ class Track(LibItem, SABase): __tablename__ = "track" - _id: int = cast(int, Column(Integer, primary_key=True)) artist: str = cast(str, Column(String, nullable=False)) artists: Optional[set[str]] = cast( - Optional[set[str]], MutableSet.as_mutable(Column(SetType, nullable=True)) + Optional[set[str]], MutableSet.as_mutable(Column(SetType, nullable=False)) ) - audio_format: str = cast(str, Column(String, nullable=True)) + audio_format: Optional[str] = cast(str, Column(String, nullable=True)) disc: int = cast(int, Column(Integer, nullable=False, default=1)) genres: Optional[set[str]] = cast( Optional[set[str]], MutableSet.as_mutable(Column(SetType, nullable=True)) ) - path: Path = cast(Path, Column(PathType, nullable=False, unique=True)) title: str = cast(str, Column(String, nullable=False)) track_num: int = cast(int, Column(Integer, nullable=False)) _custom_fields: dict[str, Any] = cast( @@ -223,7 +386,9 @@ def __init__( self.title = title self.track_num = track_num - self.artist = self.albumartist # default value + # set default values + self.artist = self.albumartist + self.audio_format = self.path.suffix for key, value in kwargs.items(): setattr(self, key, value) @@ -311,40 +476,10 @@ def from_file(cls: type[T], track_path: Path, album: Optional[Album] = None) -> **track_fields, ) - @property - def genre(self) -> Optional[str]: - """Returns a string of all genres concatenated with ';'.""" - if self.genres is None: - return None - - return ";".join(self.genres) - - @genre.setter - def genre(self, genre_str: Optional[str]): - """Sets a track's genre from a string. - - Args: - genre_str: For more than one genre, they should be split with ';'. - """ - if genre_str is None: - self.genres = None - else: - self.genres = {genre.strip() for genre in genre_str.split(";")} - @property def fields(self) -> set[str]: """Returns any editable, track-specific fields.""" - return { - "album_obj", - "artist", - "artists", - "audio_format", - "disc", - "genres", - "path", - "title", - "track_num", - }.union(set(self._custom_fields)) + return super().fields.union({"audio_format", "path"}) def is_unique(self, other: "Track") -> bool: """Returns whether a track is unique in the library from ``other``.""" @@ -364,78 +499,3 @@ def is_unique(self, other: "Track") -> bool: return False return True - - def merge(self, other: "Track", overwrite: bool = False): - """Merges another track into this one. - - Args: - other: Other track to be merged with the current track. - overwrite: Whether or not to overwrite self if a conflict exists. - """ - log.debug( - f"Merging tracks. [track_a={self!r}, track_b={other!r}, {overwrite=!r}]" - ) - - omit_fields = {"album_obj", "year"} - for field in self.fields - omit_fields: - other_value = getattr(other, field) - self_value = getattr(self, field) - if other_value and (overwrite or (not overwrite and not self_value)): - setattr(self, field, other_value) - - log.debug( - f"Tracks merged. [track_a={self!r}, track_b={other!r}, {overwrite=!r}]" - ) - - def __eq__(self, other) -> bool: - """Compares Tracks by their fields.""" - if not isinstance(other, Track): - return False - - for field in self.fields: - if not hasattr(other, field) or ( - getattr(self, field) != getattr(other, field) - ): - return False - - return True - - def __lt__(self, other) -> bool: - """Sort based on album, then disc, then track number.""" - if self.album_obj == other.album_obj: - if self.disc == other.disc: - return self.track_num < other.track_num - - return self.disc < other.disc - - return self.album_obj < other.album_obj - - def __repr__(self): - """Represents a Track using track-specific and relevant album fields.""" - field_reprs = [] - omit_fields = {"album_obj"} - for field in self.fields - omit_fields: - if hasattr(self, field): - field_reprs.append(f"{field}={getattr(self, field)!r}") - repr_str = "Track(" + ", ".join(field_reprs) + f", album='{self.album_obj}'" - - custom_field_reprs = [] - for custom_field, value in self._custom_fields.items(): - custom_field_reprs.append(f"{custom_field}={value}") - if custom_field_reprs: - repr_str += ", custom_fields=[" + ", ".join(custom_field_reprs) + "]" - - repr_str += ")" - return repr_str - - def __str__(self): - """String representation of a track.""" - return f"{self.artist} - {self.title}" - - def _get_default_custom_fields(self) -> dict[str, Any]: - """Returns the default custom track fields.""" - return { - field: default_val - for plugin_fields in config.CONFIG.pm.hook.create_custom_track_fields() - for field, default_val in plugin_fields.items() - } diff --git a/moe/plugins/moe_import/import_cli.py b/moe/plugins/moe_import/import_cli.py index c21d101b..3f94bc8d 100644 --- a/moe/plugins/moe_import/import_cli.py +++ b/moe/plugins/moe_import/import_cli.py @@ -15,7 +15,7 @@ import moe.cli from moe import config from moe.cli import console -from moe.library import Album, Track +from moe.library import Album, MetaAlbum, MetaTrack from moe.plugins.moe_import.import_core import CandidateAlbum from moe.util.cli import PromptChoice, choice_prompt from moe.util.core import get_matching_tracks @@ -212,7 +212,9 @@ def _apply_changes( if not old_track and new_track: candidate.album.tracks.remove(new_track) # missing track elif old_track and not new_track: - new_album.tracks.remove(old_track) # unmatched track + new_album.tracks.remove( + new_album.get_track(old_track.track_num, old_track.disc) + ) # unmatched track elif ( old_track and new_track @@ -293,8 +295,8 @@ def _fmt_tracks(new_album: Album, candidate: CandidateAlbum) -> Table: ) ) # sort by new track's disc then track number - unmatched_tracks: list[Track] = [] - missing_tracks: list[Track] = [] + unmatched_tracks: list[MetaTrack] = [] + missing_tracks: list[MetaTrack] = [] for old_track, new_track in matches: if old_track and new_track: track_table.add_row( @@ -308,7 +310,8 @@ def _fmt_tracks(new_album: Album, candidate: CandidateAlbum) -> Table: unmatched_tracks.append(old_track) elif not old_track and new_track: missing_tracks.append(new_track) - track_table.rows[-1].end_section = True + if track_table.rows: + track_table.rows[-1].end_section = True for missing_track in sorted(missing_tracks): track_table.add_row( @@ -331,7 +334,9 @@ def _fmt_tracks(new_album: Album, candidate: CandidateAlbum) -> Table: def _fmt_field_changes( - old_item: Union[Album, Track], new_item: Union[Album, Track], field: str + old_item: Union[MetaAlbum, MetaTrack], + new_item: Union[MetaAlbum, MetaTrack], + field: str, ) -> Optional[Text]: """Formats changes of a single field. diff --git a/moe/plugins/moe_import/import_core.py b/moe/plugins/moe_import/import_core.py index 91153d49..d8ab8334 100644 --- a/moe/plugins/moe_import/import_core.py +++ b/moe/plugins/moe_import/import_core.py @@ -9,7 +9,7 @@ import moe from moe import config -from moe.library import Album, LibItem, Track +from moe.library import Album, LibItem, MetaAlbum, Track __all__ = ["CandidateAlbum", "import_album"] @@ -33,7 +33,7 @@ class CandidateAlbum: match_value_pct (str): ``match_value`` as a percentage. """ - album: Album + album: MetaAlbum match_value: float source_str: str sub_header_info: list[str] = field(default_factory=list) diff --git a/moe/plugins/musicbrainz/mb_core.py b/moe/plugins/musicbrainz/mb_core.py index 957afada..67688788 100644 --- a/moe/plugins/musicbrainz/mb_core.py +++ b/moe/plugins/musicbrainz/mb_core.py @@ -17,7 +17,7 @@ import datetime import logging from pathlib import Path -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, cast import dynaconf import mediafile @@ -26,7 +26,7 @@ import moe from moe import config -from moe.library import Album, LibItem, Track +from moe.library import Album, LibItem, MetaAlbum, MetaTrack, Track from moe.plugins.moe_import.import_core import CandidateAlbum from moe.util.core import match @@ -213,6 +213,7 @@ def sync_metadata(item: LibItem): if isinstance(item, Album) and hasattr(item, "mb_album_id"): item.merge(get_album_by_id(item.mb_album_id), overwrite=True) elif isinstance(item, Track) and hasattr(item, "mb_track_id"): + item = cast(Track, item) item.merge( get_track_by_id(item.mb_track_id, item.album_obj.mb_album_id), overwrite=True, @@ -366,7 +367,7 @@ def set_collection(releases: set[str], collection: Optional[str] = None) -> None add_releases_to_collection(new_releases, collection) -def get_album_by_id(release_id: str) -> Album: +def get_album_by_id(release_id: str) -> MetaAlbum: """Returns an album from musicbrainz with the given release ID.""" log.debug(f"Fetching release from musicbrainz. [release={release_id!r}]") @@ -399,7 +400,7 @@ def get_candidate_by_id(album: Album, release_id: str) -> CandidateAlbum: ) -def _create_album(release: dict) -> Album: +def _create_album(release: dict) -> MetaAlbum: """Creates an album from a given musicbrainz release.""" log.debug(f"Creating album from musicbrainz release. [release={release['id']!r}]") @@ -412,7 +413,7 @@ def _create_album(release: dict) -> Album: else: label = None - album = Album( + album = MetaAlbum( artist=_flatten_artist_credit(release["artist-credit"]), barcode=release.get("barcode"), catalog_nums=catalog_nums, @@ -424,14 +425,12 @@ def _create_album(release: dict) -> Album: media=release["medium-list"][0]["format"], original_date=_parse_date(release["release-group"]["first-release-date"]), title=release["title"], - path=None, # type: ignore # this will get set in `add_prompt` ) for medium in release["medium-list"]: for track in medium["track-list"]: - Track( + MetaTrack( album=album, track_num=int(track["position"]), - path=None, # type: ignore # this will get set in `add_prompt` artist=_flatten_artist_credit(track["recording"]["artist-credit"]), disc=int(medium["position"]), mb_track_id=track["id"], @@ -472,7 +471,7 @@ def _parse_date(date: str) -> datetime.date: return datetime.date(year, month, day) -def get_track_by_id(track_id: str, album_id: str) -> Track: +def get_track_by_id(track_id: str, album_id: str) -> MetaTrack: """Gets a musicbrainz track from a given track and release id. Args: diff --git a/moe/util/core/match.py b/moe/util/core/match.py index 3181d7d5..f92d9739 100644 --- a/moe/util/core/match.py +++ b/moe/util/core/match.py @@ -4,13 +4,13 @@ import logging from typing import Optional, Union -from moe.library import Album, Track +from moe.library import MetaAlbum, MetaTrack log = logging.getLogger("moe") __all__ = ["get_match_value", "get_matching_tracks"] -TrackMatch = tuple[Optional[Track], Optional[Track]] +TrackMatch = tuple[Optional[MetaTrack], Optional[MetaTrack]] TrackCoord = tuple[ tuple[int, int], tuple[int, int] ] # ((a.disc, a.track_num), (b.disc, b.track_num)) @@ -35,12 +35,15 @@ } # how much to weigh matches of various fields -def get_match_value(item_a: Union[Album, Track], item_b: Union[Album, Track]) -> float: +def get_match_value( + item_a: Union[MetaAlbum, MetaTrack], item_b: Union[MetaAlbum, MetaTrack] +) -> float: """Returns a similarity value between two albums or tracks on a scale of 0 to 1. Args: item_a: First item to compare. - item_b: Second item to compare. + item_b: Second item to compare. Should be the same type as ``item_a`` + or a subclass i.e. ``MetaAlbums`` and ``Albums`` can be compared. Returns: The match value is a weighted sum according to the defined weights for each @@ -48,12 +51,9 @@ def get_match_value(item_a: Union[Album, Track], item_b: Union[Album, Track]) -> """ log.debug(f"Determining match value between items. [{item_a=!r}, {item_b=!r}]") - if type(item_a) is not type(item_b): - return 0 - - if isinstance(item_a, Album): + if issubclass(type(item_a), MetaAlbum): field_weights = MATCH_ALBUM_FIELD_WEIGHTS - elif isinstance(item_a, Track): + else: field_weights = MATCH_TRACK_FIELD_WEIGHTS penalties = [] @@ -83,7 +83,7 @@ def get_match_value(item_a: Union[Album, Track], item_b: Union[Album, Track]) -> def get_matching_tracks( # noqa: C901 (I don't see benefit from splitting) - album_a: Album, album_b: Album, match_threshold: float = 0.7 + album_a: MetaAlbum, album_b: MetaAlbum, match_threshold: float = 0.7 ) -> list[TrackMatch]: """Returns a list of tuples of track match pairs. diff --git a/pyproject.toml b/pyproject.toml index a30def57..7101933e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ markers = [ [tool.pyright] exclude = [ - "alembic", + "alembic", "tests" ] pythonPlatform = "All" diff --git a/tests/library/test_album.py b/tests/library/test_album.py index 611d4b06..a1d5b916 100644 --- a/tests/library/test_album.py +++ b/tests/library/test_album.py @@ -1,5 +1,6 @@ """Tests an Album object.""" +import datetime from datetime import date from pathlib import Path @@ -8,7 +9,7 @@ import moe from moe import config from moe.config import ExtraPlugin -from moe.library import Album, AlbumError, Extra +from moe.library import Album, AlbumError, Extra, MetaAlbum, MetaTrack, Track from moe.plugins import write as moe_write from tests.conftest import album_factory, track_factory @@ -99,6 +100,12 @@ def test_original_year(self): assert album.original_year == original_year + def test_null_original_year(self): + """Original date and therefore year can be null..""" + album = album_factory(original_date=None) + + assert album.original_year is None + def test_catalog_num(self): """Catalog_Num should concat catalog_nums.""" album = album_factory(catalog_nums={"1", "2"}) @@ -116,79 +123,112 @@ def test_set_catalog_num(self): assert album.catalog_nums == {"1", "2"} -class TestFromDir: - """Test a creating an album from a directory.""" +class TestGetTrack: + """Test `get_track`.""" - def test_dir_album(self, tmp_config): - """If a directory given, add to library as an album.""" - tmp_config() - album = album_factory(exists=True) - assert Album.from_dir(album.path) == album + def test_meta_return(self): + """Meta Albums return MetaTracks.""" + album = MetaAlbum() + track = MetaTrack(album, track_num=1, disc=1) + album.tracks.append(track) - def test_extras(self, tmp_config): - """Add any extras that are within the album directory.""" - tmp_config() - album = album_factory(exists=True) - new_album = Album.from_dir(album.path) + assert album.get_track(1, 1) is track - for extra in album.extras: - assert extra in new_album.extras + def test_album_return(self): + """Albums return Tracks.""" + album = album_factory() + assert isinstance(album.tracks[0], Track) + album.tracks[0].track_num = 1 + album.tracks[0].disc = 1 - def test_no_valid_tracks(self, tmp_path): - """Error if given directory does not contain any valid tracks.""" - empty_path = tmp_path / "empty" - empty_path.mkdir() + assert album.tracks[0] is album.get_track(1, 1) - with pytest.raises(AlbumError): - Album.from_dir(empty_path) - def test_add_multi_disc(self, tmp_config): - """We can add a multi-disc album.""" - tmp_config() +class TestGetExtra: + """Test `get_extra`.""" + + def test_get_extra(self): + """We get extras by their relative paths.""" album = album_factory(exists=True) - track1 = album.tracks[0] - track2 = album.tracks[1] - track1.disc = 1 - track2.disc = 2 - album.disc_total = 2 - moe_write.write_tags(track1) - moe_write.write_tags(track2) - track1_path = Path(album.path / "disc 01" / track1.path.name) - track2_path = Path(album.path / "disc 02" / track2.path.name) - track1_path.parent.mkdir() - track2_path.parent.mkdir() - track1.path.rename(track1_path) - track2.path.rename(track2_path) - track1.path = track1_path - track2.path = track2_path + extra = album.extras[0] + assert extra.path.is_relative_to(album.path) - album = Album.from_dir(album.path) + assert album.get_extra(extra.path.relative_to(album.path)) is extra - assert album.get_track(track1.track_num, track1.disc) - assert album.get_track(track2.track_num, track2.disc) +class TestMetaAlbumMerge: + """Test merging two MetaAlbums together.""" -class TestIsUnique: - """Test `is_unique()`.""" + def test_conflict_persists(self): + """Don't overwrite any conflicts.""" + album = MetaAlbum(title="123") + other_album = MetaAlbum(title="456") + keep_title = album.title - def test_same_path(self): - """Albums with the same path are not unique.""" - album = album_factory() - dup_album = album_factory(path=album.path) + album.merge(other_album) - assert not album.is_unique(dup_album) + assert album.title == keep_title - def test_default(self): - """Albums with no matching parameters are unique.""" - album1 = album_factory() - album2 = album_factory() + def test_merge_non_conflict(self): + """Apply any non-conflicting fields.""" + album = MetaAlbum(title="") + other_album = MetaAlbum(title="new") - assert album1.is_unique(album2) + album.merge(other_album) + + assert album.title == "new" + + def test_none_merge(self): + """Don't merge in any null values.""" + album = MetaAlbum(title="123") + other_album = MetaAlbum(title="") + + album.merge(other_album) + + assert album.title == "123" + + def test_overwrite_field(self): + """Overwrite fields if the option is given.""" + album = MetaAlbum(title="123") + other_album = MetaAlbum(title="456") + keep_title = other_album.title + + album.merge(other_album, overwrite=True) + + assert album.title == keep_title + + def test_merge_tracks(self): + """Tracks should merge with the same behavior as fields.""" + album1 = MetaAlbum() + album2 = MetaAlbum() + + new_track = MetaTrack(album2, 2) + conflict_track = MetaTrack(album2, 1) + keep_track = MetaTrack(album1, 1, title="keep") + assert conflict_track.title != keep_track.title + assert album1.tracks != album2.tracks + + album1.merge(album2) + assert new_track in album1.tracks + assert keep_track.title == "keep" + + def test_overwrite_tracks(self): + """Tracks should overwrite the same as fields if option given.""" + album1 = MetaAlbum() + album2 = MetaAlbum() + + MetaTrack(album2, 1, title="conflict") + overwrite_track = MetaTrack(album1, 1) -class TestMerge: - """Test merging two albums together.""" + album1.merge(album2, overwrite=True) + + assert overwrite_track.title == "conflict" + + +class TestAlbumMerge: + """Test merging two Albums together.""" def test_conflict_persists(self): """Don't overwrite any conflicts.""" @@ -294,6 +334,77 @@ def test_overwrite_tracks(self): assert overwrite_track.title == "conflict" +class TestFromDir: + """Test a creating an album from a directory.""" + + def test_dir_album(self, tmp_config): + """If a directory given, add to library as an album.""" + tmp_config() + album = album_factory(exists=True) + assert Album.from_dir(album.path) == album + + def test_extras(self, tmp_config): + """Add any extras that are within the album directory.""" + tmp_config() + album = album_factory(exists=True) + new_album = Album.from_dir(album.path) + + for extra in album.extras: + assert extra in new_album.extras + + def test_no_valid_tracks(self, tmp_path): + """Error if given directory does not contain any valid tracks.""" + empty_path = tmp_path / "empty" + empty_path.mkdir() + + with pytest.raises(AlbumError): + Album.from_dir(empty_path) + + def test_add_multi_disc(self, tmp_config): + """We can add a multi-disc album.""" + tmp_config() + album = album_factory(exists=True) + track1 = album.tracks[0] + track2 = album.tracks[1] + track1.disc = 1 + track2.disc = 2 + album.disc_total = 2 + moe_write.write_tags(track1) + moe_write.write_tags(track2) + + track1_path = Path(album.path / "disc 01" / track1.path.name) + track2_path = Path(album.path / "disc 02" / track2.path.name) + track1_path.parent.mkdir() + track2_path.parent.mkdir() + track1.path.rename(track1_path) + track2.path.rename(track2_path) + track1.path = track1_path + track2.path = track2_path + + album = Album.from_dir(album.path) + + assert album.get_track(track1.track_num, track1.disc) + assert album.get_track(track2.track_num, track2.disc) + + +class TestIsUnique: + """Test `is_unique()`.""" + + def test_same_path(self): + """Albums with the same path are not unique.""" + album = album_factory() + dup_album = album_factory(path=album.path) + + assert not album.is_unique(dup_album) + + def test_default(self): + """Albums with no matching parameters are unique.""" + album1 = album_factory() + album2 = album_factory() + + assert album1.is_unique(album2) + + class TestEquality: """Test equality of albums.""" @@ -314,3 +425,34 @@ def test_not_equals(self): def test_not_equals_not_album(self): """Not equal if not comparing two albums.""" assert album_factory() != "test" + + +class TestLessThan: + """Test ``__lt__``.""" + + def test_title_sort(self): + """Sorting by title first.""" + album1 = MetaAlbum(title="a", artist="a", date=datetime.date(2000, 1, 1)) + album2 = MetaAlbum(title="b", artist="a", date=datetime.date(2000, 1, 1)) + album3 = MetaAlbum(artist="a", date=datetime.date(2000, 1, 1)) + + assert album1 < album2 + assert album2 < album3 + + def test_artist_sort(self): + """If the title is the same, sort by artist.""" + album1 = MetaAlbum(title="a", artist="a", date=datetime.date(2000, 1, 1)) + album2 = MetaAlbum(title="a", artist="b", date=datetime.date(2000, 1, 1)) + album3 = MetaAlbum(title="a", date=datetime.date(2000, 1, 1)) + + assert album1 < album2 + assert album2 < album3 + + def test_date_sort(self): + """If the title and artist are the same, sort by date.""" + album1 = MetaAlbum(title="a", artist="a", date=datetime.date(1999, 1, 1)) + album2 = MetaAlbum(title="a", artist="a", date=datetime.date(2000, 1, 1)) + album3 = MetaAlbum(title="a", artist="a") + + assert album1 < album2 + assert album2 < album3 diff --git a/tests/library/test_track.py b/tests/library/test_track.py index 85b0e72b..37a89a7c 100644 --- a/tests/library/test_track.py +++ b/tests/library/test_track.py @@ -5,7 +5,8 @@ import moe import moe.plugins.write as moe_write from moe.config import ExtraPlugin -from moe.library import Track, TrackError +from moe.library import MetaTrack, Track, TrackError +from moe.library.album import MetaAlbum from tests.conftest import album_factory, extra_factory, track_factory @@ -236,6 +237,15 @@ def test_db_delete(self, tmp_session): assert tmp_session.query(Track).one() + def test_overwrite(self): + """Fields are overwritten if the option is given.""" + track = track_factory(audio_format="1") + other_track = track_factory(audio_format="2") + + track.merge(other_track, overwrite=True) + + assert track.audio_format == "2" + class TestProperties: """Test various track properties.""" @@ -272,3 +282,28 @@ def test_genre(self, tmp_session): tracks = tmp_session.query(Track).all() for track in tracks: assert track.genre == "pop" + + +class TestLessThan: + """Test ``__lt__``.""" + + def test_album_sort(self): + """Sorting by album_obj first.""" + track1 = MetaTrack(album=MetaAlbum(title="a"), track_num=2) + track2 = MetaTrack(album=MetaAlbum(title="b"), track_num=1) + + assert track1 < track2 + + def test_track_num_sort(self): + """Sorting by track_number if the albums are the same.""" + track1 = MetaTrack(album=MetaAlbum(title="a"), track_num=1) + track2 = MetaTrack(album=MetaAlbum(title="a"), track_num=2) + + assert track1 < track2 + + def test_disc_sort(self): + """Sorting by disc if the track numbers and albums are the same.""" + track1 = MetaTrack(album=MetaAlbum(title="a"), track_num=1, disc=1) + track2 = MetaTrack(album=MetaAlbum(title="a"), track_num=1, disc=2) + + assert track1 < track2 diff --git a/tests/plugins/musicbrainz/resources/full_release.py b/tests/plugins/musicbrainz/resources/full_release.py index 7d628e2e..22876059 100644 --- a/tests/plugins/musicbrainz/resources/full_release.py +++ b/tests/plugins/musicbrainz/resources/full_release.py @@ -4,7 +4,7 @@ import datetime from unittest.mock import MagicMock -from moe.library import Album, Track +from moe.library import MetaAlbum, MetaTrack # as returned by `musicbrainzngs.search_releases()` search = { @@ -1685,9 +1685,9 @@ } -def album() -> Album: +def album() -> MetaAlbum: """Creates an album with the above release information.""" - return Album( + album = MetaAlbum( artist="Kanye West", title="My Beautiful Dark Twisted Fantasy", barcode="602527474465", @@ -1697,20 +1697,26 @@ def album() -> Album: label="Roc‐A‐Fella Records", media="CD", original_date=datetime.date(2010, 1, 22), - path=None, # type: ignore mb_album_id="2fcfcaaa-6594-4291-b79f-2d354139e108", track_total=2, + disc_total=1, ) - -def track() -> Track: - """Creates a track from the above release.""" - return Track( - album=album(), + MetaTrack( + album=album, track_num=1, - path=None, # type: ignore artist="Kanye West", title="Dark Fantasy", + disc=1, mb_track_id="219e6b01-c962-355c-8a87-5d4ab3fc13bc", + ) + MetaTrack( + album=album, + track_num=2, + artist="Kanye West", + title="Gorgeous", disc=1, + mb_track_id="b3c6aa0a-6960-4db6-bf27-ed50de88309c", ) + + return album diff --git a/tests/util/core/test_match.py b/tests/util/core/test_match.py index ddcfecff..afdb252e 100644 --- a/tests/util/core/test_match.py +++ b/tests/util/core/test_match.py @@ -115,10 +115,6 @@ def mock_get_value(track_a, track_b): class TestMatchValue: """Test ``get_match_value()``.""" - def test_different_type(self): - """Tracks cannot match with albums.""" - assert get_match_value(album_factory(), track_factory()) == 0 - def test_same_album(self): """Albums with the same values for all used fields should be a perfect match.""" album1 = album_factory()