Skip to content

Commit

Permalink
feat: new album field - catalog_nums
Browse files Browse the repository at this point in the history
Multi-value field for catalog numbers of an album.
  • Loading branch information
jtpavlock committed Oct 13, 2022
1 parent ebcfd3d commit 01c7170
Show file tree
Hide file tree
Showing 16 changed files with 141 additions and 34 deletions.
34 changes: 34 additions & 0 deletions 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")
1 change: 1 addition & 0 deletions docs/fields.rst
Expand Up @@ -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", ""
Expand Down
38 changes: 38 additions & 0 deletions moe/library/album.py
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand All @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -223,6 +244,7 @@ def fields(self) -> set[str]:
return {
"artist",
"barcode",
"catalog_nums",
"country",
"date",
"disc_total",
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions moe/library/lib_item.py
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions moe/library/track.py
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions moe/plugins/musicbrainz/mb_core.py
Expand Up @@ -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:
Expand Down Expand Up @@ -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"]),
Expand Down
1 change: 1 addition & 0 deletions moe/plugins/write.py
Expand Up @@ -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
Expand Down
45 changes: 20 additions & 25 deletions moe/query.py
Expand Up @@ -4,6 +4,7 @@
import re
import shlex
from pathlib import Path
from typing import Type

import sqlalchemy as sa
import sqlalchemy.orm
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions moe/util/core/match.py
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions tests/library/test_album.py
Expand Up @@ -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."""
Expand Down
2 changes: 2 additions & 0 deletions tests/library/test_track.py
Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 12 additions & 2 deletions tests/plugins/musicbrainz/resources/full_release.py
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion tests/plugins/musicbrainz/test_mb_core.py
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions tests/plugins/test_write.py
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions tests/test_query.py
Expand Up @@ -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())
Expand Down

0 comments on commit 01c7170

Please sign in to comment.