Skip to content

Commit

Permalink
add Add to Collection plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
dvirtz committed Feb 17, 2024
1 parent 7b3b379 commit a1d0903
Show file tree
Hide file tree
Showing 11 changed files with 480 additions and 0 deletions.
12 changes: 12 additions & 0 deletions plugins/add_to_collection/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Add to Collection

This plugin allows you to add any saved release to one of your user release [collections](https://musicbrainz.org/doc/Collections).

Download [here](https://picard.musicbrainz.org/api/v2/download?id=add_to_collection).

---

The plugin adds a settings page under the "Plugins" section under "Options..." from Picard's main menu that lets you choose
the collection you want to add the releases to

![settings](assets/settings.png)
6 changes: 6 additions & 0 deletions plugins/add_to_collection/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from picard.plugins.add_to_collection.manifest import *
from picard.plugins.add_to_collection.options import register_options
from picard.plugins.add_to_collection.post_save_processor import register_processor

register_options()
register_processor()
Binary file added plugins/add_to_collection/assets/settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions plugins/add_to_collection/manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
PLUGIN_NAME = "Add to Collection"
PLUGIN_AUTHOR = "Dvir Yitzchaki ([email protected])"
PLUGIN_DESCRIPTION = "Adds any saved release to one of your user collections"
PLUGIN_VERSION = "0.1"
PLUGIN_API_VERSIONS = ["2.0"]
PLUGIN_LICENSE = ("MIT",)
PLUGIN_LICENSE_URL = "https://spdx.org/licenses/MIT.html"
PLUGIN_USER_GUIDE_URL = (
"https://github.com/metabrainz/picard-plugins/plugins/add-to-collection/README.md"
)
42 changes: 42 additions & 0 deletions plugins/add_to_collection/options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from picard.collection import Collection, user_collections
from picard.ui.options import OptionsPage, register_options_page

from picard.plugins.add_to_collection import settings
from picard.plugins.add_to_collection.manifest import PLUGIN_NAME
from picard.plugins.add_to_collection.ui_add_to_collection_options import (
Ui_AddToCollectionOptions,
)
from picard.plugins.add_to_collection.override_module import override_module


class AddToCollectionOptionsPage(OptionsPage):
NAME = "add-to-collection"
TITLE = PLUGIN_NAME
PARENT = "plugins"

options = [settings.collection_id_option()]

def __init__(self, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_AddToCollectionOptions()
self.ui.setupUi(self)

def load(self) -> None:
self.set_collection_name(settings.collection_id())

def save(self) -> None:
settings.set_collection_id(self.ui.collection_name.currentData())

def set_collection_name(self, value: str) -> None:
self.ui.collection_name.clear()
collection: Collection
for collection in user_collections.values():
self.ui.collection_name.addItem(collection.name, collection.id)
idx = self.ui.collection_name.findData(value)
if idx != -1:
self.ui.collection_name.setCurrentIndex(idx)


def register_options() -> None:
with override_module(AddToCollectionOptionsPage):
register_options_page(AddToCollectionOptionsPage)
11 changes: 11 additions & 0 deletions plugins/add_to_collection/override_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from contextlib import contextmanager
from typing import Generator


@contextmanager
def override_module(object: object) -> Generator[None, None, None]:

Check warning on line 6 in plugins/add_to_collection/override_module.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

plugins/add_to_collection/override_module.py#L6

Redefining built-in 'object'
# picard expects hooks to be defined at module level
module = object.__module__
object.__module__ = ".".join(module.split(".")[:-1])
yield
object.__module__ = module
26 changes: 26 additions & 0 deletions plugins/add_to_collection/post_save_processor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from picard import log
from picard.collection import Collection, user_collections
from picard.file import File, register_file_post_save_processor

from picard.plugins.add_to_collection import settings
from picard.plugins.add_to_collection.override_module import override_module


def post_save_processor(file: File) -> None:
collection_id = settings.collection_id()
if not collection_id:
log.error(f"cannot find collection ID setting")

Check notice on line 12 in plugins/add_to_collection/post_save_processor.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

plugins/add_to_collection/post_save_processor.py#L12

f-string is missing placeholders (F541)
return
collection: Collection = user_collections.get(collection_id)
if not collection:
log.error(f"cannot find collection with id {collection_id}")
return
release_id = file.metadata["musicbrainz_albumid"]
if release_id not in collection.releases:
log.debug("Adding release %r to %r", release_id, collection.name)
collection.add_releases(set([release_id]), callback=lambda: None)


def register_processor() -> None:
with override_module(post_save_processor):
register_file_post_save_processor(post_save_processor)
20 changes: 20 additions & 0 deletions plugins/add_to_collection/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from picard.config import TextOption, get_config
from typing import Optional

COLLECTION_ID = "add_to_collection_id"


def collection_id_option() -> TextOption:
return TextOption(section="setting", name=COLLECTION_ID, default=None)


def collection_id() -> Optional[str]:
config = get_config()
if COLLECTION_ID in config.setting:
return config.setting[COLLECTION_ID]
return None


def set_collection_id(value: str) -> None:
config = get_config()
config.setting[COLLECTION_ID] = value
37 changes: 37 additions & 0 deletions plugins/add_to_collection/ui_add_to_collection_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from PyQt5 import QtCore, QtWidgets


class Ui_AddToCollectionOptions(object):
def setupUi(self, AddToCollectionOptions):
AddToCollectionOptions.setObjectName("AddToCollectionOptions")
AddToCollectionOptions.resize(472, 215)
self.verticalLayout = QtWidgets.QVBoxLayout(AddToCollectionOptions)
self.verticalLayout.setObjectName("verticalLayout")
self.collection_label = QtWidgets.QLabel(AddToCollectionOptions)
self.collection_label.setObjectName("collection_label")
self.verticalLayout.addWidget(self.collection_label)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Fixed
)
self.collection_name = QtWidgets.QComboBox(AddToCollectionOptions)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.collection_name.sizePolicy().hasHeightForWidth()
)
self.collection_name.setSizePolicy(sizePolicy)
self.collection_name.setEditable(False)
self.collection_name.setObjectName("collection_name")
self.verticalLayout.addWidget(self.collection_name)
spacerItem = QtWidgets.QSpacerItem(
20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding
)
self.verticalLayout.addItem(spacerItem)

self.retranslateUi(AddToCollectionOptions)
QtCore.QMetaObject.connectSlotsByName(AddToCollectionOptions)

def retranslateUi(self, AddToCollectionOptions):
_translate = QtCore.QCoreApplication.translate
AddToCollectionOptions.setWindowTitle(_("Form"))
self.collection_label.setText(_("Collection to add releases to:"))
178 changes: 178 additions & 0 deletions test/plugin_test_case.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2018 Wieland Hoffmann
# Copyright (C) 2019-2023 Philipp Wolfer
# Copyright (C) 2020-2021 Laurent Monin
# Copyright (C) 2021 Bob Swift
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.


import importlib
import logging
import os
import shutil
import sys
import unittest
from os import PathLike

Check warning on line 31 in test/plugin_test_case.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

test/plugin_test_case.py#L31

Unused PathLike imported from os
from tempfile import mkdtemp, mkstemp
from types import ModuleType
from typing import Any, Callable, Optional
from unittest.mock import Mock, patch

import picard
from picard import config, log
from picard.i18n import setup_gettext
from picard.plugin import _PLUGIN_MODULE_PREFIX, _unregister_module_extensions
from picard.pluginmanager import PluginManager, plugin_dirs

Check warning on line 41 in test/plugin_test_case.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

test/plugin_test_case.py#L41

Unused plugin_dirs imported from picard.pluginmanager
from picard.releasegroup import ReleaseGroup
from PyQt5 import QtCore


class FakeThreadPool(QtCore.QObject):

def start(self, runnable, priority):
runnable.run()


class FakeTagger(QtCore.QObject):

tagger_stats_changed = QtCore.pyqtSignal()

def __init__(self):
QtCore.QObject.__init__(self)
QtCore.QObject.config = config
QtCore.QObject.log = log
self.tagger_stats_changed.connect(self.emit)
self.exit_cleanup = []
self.files = {}
self.stopping = False
self.thread_pool = FakeThreadPool()
self.priority_thread_pool = FakeThreadPool()
self.save_thread_pool = FakeThreadPool()
self.mb_api = Mock()

def register_cleanup(self, func: Callable[[], Any]) -> None:
self.exit_cleanup.append(func)

def run_cleanup(self) -> None:
for f in self.exit_cleanup:
f()

def emit(self, *args) -> None:
pass

def get_release_group_by_id(self, rg_id: str) -> ReleaseGroup:
return ReleaseGroup(rg_id)


class PluginTestCase(unittest.TestCase):
def setUp(self) -> None:
log.set_level(logging.DEBUG)
setup_gettext(None, "C")
self.tagger = FakeTagger()
QtCore.QObject.tagger = self.tagger
QtCore.QCoreApplication.instance = lambda: self.tagger
self.addCleanup(self.tagger.run_cleanup)
self.init_config()

self.tmp_directory = self.mktmpdir()
# return tmp_directory from pluginmanager.plugin_dirs
self.patchers = [
patch(
"picard.pluginmanager.plugin_dirs", return_value=[self.tmp_directory]
).start(),
patch(
"picard.util.thread.to_main",
side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
).start(),
]

def tearDown(self) -> None:
for patcher in self.patchers:
patcher.stop()

@staticmethod
def init_config() -> None:
fake_config = Mock()
fake_config.setting = {}
fake_config.persist = {}
fake_config.profiles = {}
# Make config object available for legacy use
config.config = fake_config
config.setting = fake_config.setting
config.persist = fake_config.persist
config.profiles = fake_config.profiles

@staticmethod
def set_config_values(
setting: Optional[dict] = None,
persist: Optional[dict] = None,
profiles: Optional[dict] = None,
) -> None:
if setting:
for key, value in setting.items():
config.config.setting[key] = value
if persist:
for key, value in persist.items():
config.config.persist[key] = value
if profiles:
for key, value in profiles.items():
config.config.profiles[key] = value

def mktmpdir(self, ignore_errors: bool = False) -> None:
tmpdir = mkdtemp(suffix=self.__class__.__name__)
self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=ignore_errors)
return tmpdir

def copy_file_tmp(self, filepath: str, ext: Optional[str] = None) -> str:
fd, copy = mkstemp(suffix=ext)
os.close(fd)
self.addCleanup(self.remove_file_tmp, copy)
shutil.copy(filepath, copy)
return copy

@staticmethod
def remove_file_tmp(filepath: str) -> None:
if os.path.isfile(filepath):
os.unlink(filepath)

def _test_plugin_install(self, name: str, module: str) -> ModuleType:
self.unload_plugin(module)
with self.assertRaises(ImportError):
importlib.import_module(f"picard.plugins.{module}")

pm = PluginManager(plugins_directory=self.tmp_directory)

msg = f"install_plugin: {name} from {module}"
pm.install_plugin(os.path.join("plugins", module))
self.assertEqual(len(pm.plugins), 1, msg)
self.assertEqual(pm.plugins[0].name, name, msg)

self.set_config_values(setting={"enabled_plugins": [module]})

# if module is properly loaded, this should work
return importlib.import_module(f"picard.plugins.{module}")

def unload_plugin(self, plugin_name: str) -> None:
"""for testing purposes"""
_unregister_module_extensions(plugin_name)
if hasattr(picard.plugins, plugin_name):
delattr(picard.plugins, plugin_name)
key = _PLUGIN_MODULE_PREFIX + plugin_name
if key in sys.modules:
del sys.modules[key]
Loading

0 comments on commit a1d0903

Please sign in to comment.