From 01c71707eb80f249c9709b820b40b2f6938b8c34 Mon Sep 17 00:00:00 2001 From: Jacob Pavlock Date: Wed, 12 Oct 2022 17:25:52 -0700 Subject: [PATCH] feat: new album field - catalog_nums Multi-value field for catalog numbers of an album. --- .../0ce5960a081e_new_field_catalog_nums.py | 34 ++++++++++++++ docs/fields.rst | 1 + moe/library/album.py | 38 ++++++++++++++++ moe/library/lib_item.py | 1 + moe/library/track.py | 1 + moe/plugins/musicbrainz/mb_core.py | 7 +++ moe/plugins/write.py | 1 + moe/query.py | 45 +++++++++---------- moe/util/core/match.py | 1 + pyproject.toml | 2 +- tests/library/test_album.py | 8 ++++ tests/library/test_track.py | 2 + .../musicbrainz/resources/full_release.py | 14 +++++- tests/plugins/musicbrainz/test_mb_core.py | 3 +- tests/plugins/test_write.py | 3 ++ tests/test_query.py | 14 +++--- 16 files changed, 141 insertions(+), 34 deletions(-) create mode 100644 alembic/versions/0ce5960a081e_new_field_catalog_nums.py diff --git a/alembic/versions/0ce5960a081e_new_field_catalog_nums.py b/alembic/versions/0ce5960a081e_new_field_catalog_nums.py new file mode 100644 index 00000000..6aac9fe5 --- /dev/null +++ b/alembic/versions/0ce5960a081e_new_field_catalog_nums.py @@ -0,0 +1,34 @@ +"""new field catalog_nums. + +Revision ID: 0ce5960a081e +Revises: 32e9fea590b7 +Create Date: 2022-10-12 16:13:33.297005 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0ce5960a081e" +down_revision = "32e9fea590b7" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "catalog_num", + sa.Column("_id", sa.Integer(), nullable=False), + sa.Column("_album_id", sa.Integer(), nullable=True), + sa.Column("catalog_num", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["_album_id"], + ["album._id"], + ), + sa.PrimaryKeyConstraint("_id"), + ) + + +def downgrade(): + op.drop_table("catalog_num") diff --git a/docs/fields.rst b/docs/fields.rst index 7172acb3..b149c46c 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -35,6 +35,7 @@ Album Fields "artist", "Album artist", "" "barcode", "UPC barcode", "" + "catalog_num", "Catalog numbers of the album.", "1" "country", "Country the album was released in (two character identifier)", "" "date", "Album release date", "2" "disc_total", "Number of discs in the album", "" diff --git a/moe/library/album.py b/moe/library/album.py index aafea764..2008d58b 100644 --- a/moe/library/album.py +++ b/moe/library/album.py @@ -8,9 +8,11 @@ import pluggy import sqlalchemy as sa from sqlalchemy import JSON, Column, Date, Integer, String +from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.mutable import MutableDict from sqlalchemy.orm import relationship +from sqlalchemy.schema import ForeignKey import moe from moe import config @@ -74,6 +76,19 @@ class AlbumError(LibraryError): """Error performing some operation on an Album.""" +class _CatalogNums(SABase): + """An album can have multiple catalog numbers.""" + + __tablename__ = "catalog_num" + + _id: int = cast(int, Column(Integer, primary_key=True)) + _album_id: int = cast(int, Column(Integer, ForeignKey("album._id"))) + catalog_num: str = cast(str, Column(String, nullable=False)) + + def __init__(self, catalog_num: str): + self.catalog_num = catalog_num + + # Album generic, used for typing classmethod A = TypeVar("A", bound="Album") @@ -86,6 +101,7 @@ class Album(LibItem, SABase): 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). date (datetime.date): Album release date. disc_total (int): Number of discs in the album. @@ -127,6 +143,11 @@ class Album(LibItem, SABase): ), ) + _catalog_nums: set[_CatalogNums] = relationship( + "_CatalogNums", collection_class=set, cascade="save-update, merge, expunge" + ) + catalog_nums: set[str] = association_proxy("_catalog_nums", "catalog_num") + tracks: list["Track"] = relationship( "Track", back_populates="album_obj", @@ -223,6 +244,7 @@ def fields(self) -> set[str]: return { "artist", "barcode", + "catalog_nums", "country", "date", "disc_total", @@ -236,6 +258,22 @@ def fields(self) -> set[str]: "tracks", }.union(self._custom_fields) + @property + def catalog_num(self) -> str: + """Returns a string of all catalog numbers concatenated with ';'.""" + return ";".join(self.catalog_nums) + + @catalog_num.setter + def catalog_num(self, catalog_num: str): + """Sets an album's catalog_nums from a string. + + Args: + catalog_num: For more than one catalog_num, they should be split with ';'. + """ + self.catalog_nums = { + catalog_num.strip() for catalog_num in catalog_num.split(";") + } + def get_extra(self, rel_path: PurePath) -> Optional["Extra"]: """Gets an Extra by its path.""" return next( diff --git a/moe/library/lib_item.py b/moe/library/lib_item.py index 792bc893..3360d723 100644 --- a/moe/library/lib_item.py +++ b/moe/library/lib_item.py @@ -208,6 +208,7 @@ def _process_after_flush( class LibItem: """Base class for library items i.e. Albums, Extras, and Tracks.""" + _custom_fields = {} _custom_fields_set = None @property diff --git a/moe/library/track.py b/moe/library/track.py index 6f01ee3e..6c9ee3ce 100644 --- a/moe/library/track.py +++ b/moe/library/track.py @@ -112,6 +112,7 @@ def read_custom_tags( album_fields["artist"] = audio_file.albumartist or audio_file.artist album_fields["barcode"] = audio_file.barcode + album_fields["catalog_nums"] = set(audio_file.catalognums) album_fields["country"] = audio_file.country album_fields["date"] = audio_file.date album_fields["disc_total"] = audio_file.disctotal diff --git a/moe/plugins/musicbrainz/mb_core.py b/moe/plugins/musicbrainz/mb_core.py index b7a1dd37..957afada 100644 --- a/moe/plugins/musicbrainz/mb_core.py +++ b/moe/plugins/musicbrainz/mb_core.py @@ -135,6 +135,8 @@ def get_candidates(album: Album) -> list[CandidateAlbum]: search_criteria["mediums"] = album.disc_total if album.barcode: search_criteria["barcode"] = album.barcode + if album.catalog_nums: + search_criteria["catno"] = next(iter(album.catalog_nums)) # get any cat_num if album.label: search_criteria["label"] = album.label if album.mb_album_id: @@ -401,14 +403,19 @@ def _create_album(release: dict) -> Album: """Creates an album from a given musicbrainz release.""" log.debug(f"Creating album from musicbrainz release. [release={release['id']!r}]") + catalog_nums = set() if release["label-info-list"]: label = release["label-info-list"][0]["label"]["name"] + for label_info in release["label-info-list"]: + if label_info.get("catalog-number"): + catalog_nums.add(label_info["catalog-number"]) else: label = None album = Album( artist=_flatten_artist_credit(release["artist-credit"]), barcode=release.get("barcode"), + catalog_nums=catalog_nums, country=release.get("country"), date=_parse_date(release["date"]), disc_total=int(release["medium-count"]), diff --git a/moe/plugins/write.py b/moe/plugins/write.py index 95fcdc31..febd61cf 100644 --- a/moe/plugins/write.py +++ b/moe/plugins/write.py @@ -81,6 +81,7 @@ def write_custom_tags(track: Track): audio_file.artist = track.artist audio_file.artists = track.artists audio_file.barcode = track.album_obj.barcode + audio_file.catalognums = track.album_obj.catalog_nums audio_file.country = track.album_obj.country audio_file.date = track.album_obj.date audio_file.disc = track.disc diff --git a/moe/query.py b/moe/query.py index fa59fac7..caac7133 100644 --- a/moe/query.py +++ b/moe/query.py @@ -4,6 +4,7 @@ import re import shlex from pathlib import Path +from typing import Type import sqlalchemy as sa import sqlalchemy.orm @@ -212,33 +213,27 @@ def _create_filter_expression(field_type: str, field: str, separator: str, value def _get_field_attr(field: str, field_type: str): """Gets the corresponding attribute for the given field to use in a query filter.""" - if field == "genre": + # convert singular multi-value fields to their plural equivalents + if field == "catalog_num" and field_type == "album": + field = "catalog_nums" + elif field == "genre" and field_type == "track": field = "genres" if field_type == "album": - try: - return getattr(Album, field) - except AttributeError: - # assume custom field - custom_func = sa.func.json_each( - Album._custom_fields, f"$.{field}" - ).table_valued("value", joins_implicitly=True) - return custom_func.c.value + return _getattr(Album, field) elif field_type == "extra": - try: - return getattr(Extra, field) - except AttributeError: - # assume custom field - custom_func = sa.func.json_each( - Extra._custom_fields, f"$.{field}" - ).table_valued("value", joins_implicitly=True) - return custom_func.c.value + return _getattr(Extra, field) else: - try: - return getattr(Track, field) - except AttributeError: - # assume custom field - custom_func = sa.func.json_each( - Track._custom_fields, f"$.{field}" - ).table_valued("value", joins_implicitly=True) - return custom_func.c.value + return _getattr(Track, field) + + +def _getattr(item_class: Type[LibItem], field: str): + """Get an attribute for the given class type.""" + try: + return getattr(item_class, field) + except AttributeError: + # assume custom field + custom_func = sa.func.json_each( + item_class._custom_fields, f"$.{field}" + ).table_valued("value", joins_implicitly=True) + return custom_func.c.value diff --git a/moe/util/core/match.py b/moe/util/core/match.py index f7633594..3181d7d5 100644 --- a/moe/util/core/match.py +++ b/moe/util/core/match.py @@ -18,6 +18,7 @@ MATCH_ALBUM_FIELD_WEIGHTS = { "artist": 0.8, "barcode": 1.0, + "catalog_nums": 1.0, "country": 0.3, "date": 0.2, "disc_total": 0.8, diff --git a/pyproject.toml b/pyproject.toml index 6ea9998b..d550752e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ moe = 'moe.cli:main' python = ">=3.9, <3.11" alembic = "^1.4.2" dynaconf = "^3.1.4" -mediafile = "^0.9.0" +mediafile = {git = "https://github.com/beetbox/mediafile.git"} musicbrainzngs = "^0.7.1" pluggy = "^0.13.1" pyyaml = "^5.3.1" diff --git a/tests/library/test_album.py b/tests/library/test_album.py index 13255752..0010419f 100644 --- a/tests/library/test_album.py +++ b/tests/library/test_album.py @@ -99,6 +99,14 @@ def test_original_year(self): assert album.original_year == original_year + def test_catalog_num(self): + """We can set and read the str conversion of `catalog_nums`.""" + album = album_factory(catalog_nums={"1", "2"}) + assert album.catalog_num == "1;2" or album.catalog_num == "2;1" + + album.catalog_num = "1;3" + assert album.catalog_nums == {"1", "3"} + class TestFromDir: """Test a creating an album from a directory.""" diff --git a/tests/library/test_track.py b/tests/library/test_track.py index e3eefb61..da5b2153 100644 --- a/tests/library/test_track.py +++ b/tests/library/test_track.py @@ -127,6 +127,7 @@ def test_read_tags(self, tmp_config): track.track_num = 1 album.barcode = "1234" + album.catalog_nums = {"1", "2"} album.country = "US" album.date = datetime.date(2020, 1, 12) album.disc_total = 2 @@ -149,6 +150,7 @@ def test_read_tags(self, tmp_config): assert new_track.track_num == track.track_num assert new_album.barcode == album.barcode + assert new_album.catalog_nums == album.catalog_nums assert new_album.country == album.country assert new_album.disc_total == album.disc_total assert new_album.date == album.date diff --git a/tests/plugins/musicbrainz/resources/full_release.py b/tests/plugins/musicbrainz/resources/full_release.py index 8a61dd30..7d628e2e 100644 --- a/tests/plugins/musicbrainz/resources/full_release.py +++ b/tests/plugins/musicbrainz/resources/full_release.py @@ -205,9 +205,18 @@ "name": "Roc‐A‐Fella Records", "sort-name": "Roc‐A‐Fella Records", }, - } + }, + { + "catalog-number": "B0014695-01", + "label": { + "id": "4cccc72a-0bd0-433a-905e-dad87871397d", + "type": "Original Production", + "name": "Roc‐A‐Fella Records", + "sort-name": "Roc‐A‐Fella Records", + }, + }, ], - "label-info-count": 1, + "label-info-count": 2, "medium-list": [ { "position": "1", @@ -1682,6 +1691,7 @@ def album() -> Album: artist="Kanye West", title="My Beautiful Dark Twisted Fantasy", barcode="602527474465", + catalog_nums={"B0014695-02", "B0014695-01"}, country="US", date=datetime.date(2010, 11, 22), label="Roc‐A‐Fella Records", diff --git a/tests/plugins/musicbrainz/test_mb_core.py b/tests/plugins/musicbrainz/test_mb_core.py index 0b4c759f..e3ac4589 100644 --- a/tests/plugins/musicbrainz/test_mb_core.py +++ b/tests/plugins/musicbrainz/test_mb_core.py @@ -303,9 +303,10 @@ class TestGetAlbumById: You can use the following code to print the result of a musicbrainz api query. def test_print_result(self): + import musicbrainzngs album_id = "3af9a6ca-c38a-41a7-a53c-32a97e869e8e" includes = ["artist-credits", "recordings"] - print(musicbrainzngs.get_release_by_id(id, includes)) + print(musicbrainzngs.get_release_by_id(album_id, includes)) assert 0 Make sure to add any ``includes`` for whatever is needed for the test. diff --git a/tests/plugins/test_write.py b/tests/plugins/test_write.py index 587b6eb3..3073a00b 100644 --- a/tests/plugins/test_write.py +++ b/tests/plugins/test_write.py @@ -67,6 +67,7 @@ def test_write_tags(self, tmp_config): artist = "4 Non Blondes" artists = {"4 Non Blondes", "Me"} barcode = "1234" + catalog_nums = {"1", "2"} country = "US" date = datetime.date(1996, 10, 13) disc = 2 @@ -84,6 +85,7 @@ def test_write_tags(self, tmp_config): track.artist = artist track.artists = artists track.album_obj.barcode = barcode + track.album_obj.catalog_nums = catalog_nums track.album_obj.country = country track.album_obj.date = date track.album_obj.original_date = original_date @@ -111,6 +113,7 @@ def test_write_tags(self, tmp_config): assert new_track.track_num == track_num assert new_album.barcode == barcode + assert new_album.catalog_nums == catalog_nums assert new_album.country == country assert new_album.date == date assert new_album.disc_total == disc_total diff --git a/tests/test_query.py b/tests/test_query.py index b08e2b2a..02704eb2 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -217,17 +217,21 @@ def test_like_escape_query(self, tmp_session): assert len(query("a:title:/_", "album")) == 1 - def test_multi_value_query(self, tmp_session): - """We should be able to query multi-value fields transparently. - - ``genre`` is a list of genres for a Track. - """ + def test_track_genre_query(self, tmp_session): + """Querying 'genre' should use the 'genres' field.""" tmp_session.add(track_factory(genres={"hip hop", "rock"})) tmp_session.flush() assert query("'genre::.*'", "track") assert query("'genre:hip hop'", "track") + def test_album_catalog_num_query(self, tmp_session): + """Querying 'catalog_num' should use the 'catalog_nums' field.""" + tmp_session.add(album_factory(catalog_nums={"1", "2"})) + tmp_session.flush() + + assert query("a:catalog_num:1 a:catalog_num:2", "album") + def test_wildcard_query(self, tmp_session): """'*' as a query should return all items.""" tmp_session.add(album_factory())