Skip to content

Commit

Permalink
feat: add mbcol cli argument to sync music to a musicbrainz collection
Browse files Browse the repository at this point in the history
  • Loading branch information
jtpavlock committed Oct 5, 2022
1 parent fbb11d0 commit 4f00136
Show file tree
Hide file tree
Showing 3 changed files with 243 additions and 8 deletions.
40 changes: 36 additions & 4 deletions docs/plugins/musicbrainz.rst
Expand Up @@ -5,7 +5,10 @@ Musicbrainz
The ``musicbrainz`` plugin provides the following functionality:

* Imports metadata from musicbrainz when adding a track or album to the library.
* Optionally updates a musicbrainz collection when items are added or removed from the library.
* Sync releases in your library with a musicbrainz collection.

* Optionally updates the collection when items are added or removed from the library.
* Commandline option to manually set, add to, or remove releases from the collection.

*************
Configuration
Expand All @@ -18,7 +21,7 @@ The ``musicbrainz`` plugin is enabled by default.
Musicbrainz password.

Collections
-----------
===========
The following options involve auto updating a specific collection on musicbrainz, and should be specified under a ``musicbrainz.collection`` block as shown:

.. code-block:: toml
Expand All @@ -39,12 +42,41 @@ The following options involve auto updating a specific collection on musicbrainz
``auto_remove = False``
Whether to automatically remove releases from ``collection_id`` when removed from the library.

***********
Commandline
***********
The musicbrainz plugin adds a single command, ``mbcol``, used to sync a musicbrainz collection with musicbrainz releases in the library. The collection synced is the one specified under ``collection_id`` in the user config.

.. code-block:: bash
moe mbcol [-h] [-a | -e] [--add | --remove] query
By default, the musicbrainz collection will be set to the releases found in the queried items. If tracks or extras are queried, their associated album releases will be synced with the collection.

Options
=======
``-h, --help``
Display the help message.
``-a, --album``
Query for matching albums instead of tracks.
``-e, --extra``
Query for matching extras instead of tracks.
``--add``
Add releases to the collection.
``--remove``
Remove releases from the collection.

Arguments
=========
``query``
Query your library for items to sync your collection with. See the :doc:`query docs <../query>` for more info.

*************
Custom Fields
*************

Track Fields
------------
============
.. csv-table::
:header: "Field", "Description", "Notes"
:widths: 4, 10, 6
Expand All @@ -53,7 +85,7 @@ Track Fields
"mb_track_id", "Musicbrainz track id", ""

Album Fields
------------
============
.. csv-table::
:header: "Field", "Description", "Notes"
:widths: 4, 10, 6
Expand Down
74 changes: 73 additions & 1 deletion moe/plugins/musicbrainz/mb_cli.py
Expand Up @@ -3,23 +3,95 @@
The ``musicbrainz`` cli plugin provides the following functionality:
* Ability to search for a specific musicbrainz ID when importing an item.
* `mbcol` command to sync a musicbrainz collection with items in the library.
"""


import argparse
import logging
from typing import Optional

import questionary

import moe
import moe.cli
from moe.library import Album
from moe.library import Album, Extra, Track
from moe.plugins import moe_import
from moe.plugins import musicbrainz as moe_mb
from moe.query import QueryError, query
from moe.util.cli import PromptChoice

__all__: list[str] = []

log = logging.getLogger("moe.cli.mb")


@moe.hookimpl
def add_command(cmd_parsers: argparse._SubParsersAction):
"""Adds the ``mbcol`` command to Moe's CLI."""
mbcol_parser = cmd_parsers.add_parser(
"mbcol",
description="Set a musicbrainz collection to a query.",
help="sync a musicbrainz collection",
parents=[moe.cli.query_parser],
)
col_option_group = mbcol_parser.add_mutually_exclusive_group()
col_option_group.add_argument(
"--add",
action="store_true",
help="add items to a collection",
)
col_option_group.add_argument(
"--remove",
action="store_true",
help="remove items from a collection",
)
mbcol_parser.set_defaults(func=_parse_args)


def _parse_args(args: argparse.Namespace): # noqa: C901
"""Parses the given commandline arguments.
Args:
args: Commandline arguments to parse.
Raises:
SystemExit: Invalid query given, or no items to remove.
"""
try:
items = query(args.query, query_type=args.query_type)
except QueryError as err:
log.error(err)
raise SystemExit(1) from err

if not items:
log.error("Given query returned no items.")
raise SystemExit(1)

releases = set()

for item in items:
release_id: Optional[str] = None
if isinstance(item, (Extra, Track)):
release_id = item.album_obj.mb_album_id
elif isinstance(item, Album):
release_id = item.mb_album_id

if release_id:
releases.add(release_id)

if not releases:
log.error("Queried items don't contain any musicbrainz releases to sync.")
raise SystemExit(1)

if args.add:
moe_mb.add_releases_to_collection(releases)
elif args.remove:
moe_mb.rm_releases_from_collection(releases)
else:
moe_mb.set_collection(releases)


@moe.hookimpl
def add_import_prompt_choice(prompt_choices: list[PromptChoice]):
"""Adds a choice to the import prompt to allow specifying a mb id."""
Expand Down
137 changes: 134 additions & 3 deletions tests/plugins/musicbrainz/test_mb_cli.py
@@ -1,17 +1,149 @@
"""Test the musicbrainz cli plugin."""

from types import FunctionType
from typing import Iterator
from unittest.mock import Mock, patch

import pytest

import moe
import moe.cli
from moe import config
from tests.conftest import album_factory
from moe.query import QueryError
from tests.conftest import album_factory, extra_factory, track_factory


@pytest.fixture
def mock_query() -> Iterator[FunctionType]:
"""Mock a database query call.
Use ``mock_query.return_value` to set the return value of a query.
Yields:
Mock query
"""
with patch("moe.plugins.musicbrainz.mb_cli.query", autospec=True) as mock_query:
yield mock_query


@pytest.fixture
def _tmp_mb_config(tmp_config):
"""A temporary config for the edit plugin with the cli."""
tmp_config('default_plugins = ["cli", "musicbrainz"]')


@pytest.mark.usefixtures("_tmp_mb_config")
class TestCollectionCommand:
"""Test the `mbcol` command."""

def test_track(self, mock_query):
"""Tracks associated album's are used."""
cli_args = ["mbcol", "*"]
track = track_factory()
track.album_obj.mb_album_id = "123"
mock_query.return_value = [track]

with patch(
"moe.plugins.musicbrainz.mb_cli.moe_mb.set_collection", autospec=True
) as mock_set:
moe.cli.main(cli_args)

mock_set.assert_called_once_with({"123"})

def test_extra(self, mock_query):
"""Extras associated album's are used."""
cli_args = ["mbcol", "*"]
extra = extra_factory()
extra.album_obj.mb_album_id = "123"
mock_query.return_value = [extra]

with patch(
"moe.plugins.musicbrainz.mb_cli.moe_mb.set_collection", autospec=True
) as mock_set:
moe.cli.main(cli_args)

mock_set.assert_called_once_with({"123"})

def test_album(self, mock_query):
"""Albums associated releases are used."""
cli_args = ["mbcol", "*"]
album = album_factory(custom_fields={"mb_album_id": "123"})
mock_query.return_value = [album]

with patch(
"moe.plugins.musicbrainz.mb_cli.moe_mb.set_collection", autospec=True
) as mock_set:
moe.cli.main(cli_args)

mock_set.assert_called_once_with({"123"})

def test_remove(self, mock_query):
"""Releases are removed from a collection if `--remove` option used."""
cli_args = ["mbcol", "--remove", "*"]
track = track_factory()
track.album_obj.mb_album_id = "123"
mock_query.return_value = [track]

with patch(
"moe.plugins.musicbrainz.mb_cli.moe_mb.rm_releases_from_collection",
autospec=True,
) as mock_rm:
moe.cli.main(cli_args)

mock_rm.assert_called_once_with({"123"})

def test_add(self, mock_query):
"""Releases are added to a collection if `--add` option used."""
cli_args = ["mbcol", "--add", "*"]
track = track_factory()
track.album_obj.mb_album_id = "123"
mock_query.return_value = [track]

with patch(
"moe.plugins.musicbrainz.mb_cli.moe_mb.add_releases_to_collection",
autospec=True,
) as mock_add:
moe.cli.main(cli_args)

mock_add.assert_called_once_with({"123"})

def test_exit_code(self, mock_query):
"""Return a non-zero exit code if no items are returned from the query."""
cli_args = ["mbcol", "*"]
mock_query.return_value = []

with pytest.raises(SystemExit) as error:
moe.cli.main(cli_args)

assert error.value.code != 0

def test_bad_query(self, mock_query):
"""Return a non-zero exit code if a bad query is given."""
cli_args = ["mbcol", "*"]
mock_query.side_effect = QueryError

with pytest.raises(SystemExit) as error:
moe.cli.main(cli_args)

assert error.value.code != 0

def test_no_releases_found(self, mock_query):
"""Return a non-zero exit code if no releases found in the queried items."""
cli_args = ["mbcol", "*"]
mock_query.return_value = [track_factory()]

with pytest.raises(SystemExit) as error:
moe.cli.main(cli_args)

assert error.value.code != 0


@pytest.mark.usefixtures("_tmp_mb_config")
class TestAddImportPromptChoice:
"""Test the `add_import_prompt_choice` hook implementation."""

def test_add_choice(self, tmp_config):
"""The "m" key to add a musicbrainz id is added."""
tmp_config("default_plugins = ['cli', 'musicbrainz']")
prompt_choices = []

config.CONFIG.pm.hook.add_import_prompt_choice(prompt_choices=prompt_choices)
Expand All @@ -20,7 +152,6 @@ def test_add_choice(self, tmp_config):

def test_enter_id(self, tmp_config):
"""When selected, the 'm' key should allow the user to enter an mb_id."""
tmp_config("default_plugins = ['cli', 'musicbrainz']", tmp_db=True)
old_album = album_factory()
new_album = album_factory()
prompt_choices = []
Expand Down

0 comments on commit 4f00136

Please sign in to comment.