diff --git a/plugins/add_to_collection/README.md b/plugins/add_to_collection/README.md new file mode 100644 index 00000000..f8724558 --- /dev/null +++ b/plugins/add_to_collection/README.md @@ -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) diff --git a/plugins/add_to_collection/__init__.py b/plugins/add_to_collection/__init__.py new file mode 100644 index 00000000..97f8e974 --- /dev/null +++ b/plugins/add_to_collection/__init__.py @@ -0,0 +1,5 @@ +from picard.plugins.add_to_collection.manifest import * +from picard.plugins.add_to_collection import options, post_save_processor + +options.register_options() +post_save_processor.register_processor() diff --git a/plugins/add_to_collection/assets/settings.png b/plugins/add_to_collection/assets/settings.png new file mode 100644 index 00000000..4ccc6da7 Binary files /dev/null and b/plugins/add_to_collection/assets/settings.png differ diff --git a/plugins/add_to_collection/manifest.py b/plugins/add_to_collection/manifest.py new file mode 100644 index 00000000..f5e30cea --- /dev/null +++ b/plugins/add_to_collection/manifest.py @@ -0,0 +1,10 @@ +PLUGIN_NAME = "Add to Collection" +PLUGIN_AUTHOR = "Dvir Yitzchaki (dvirtz@gmail.com)" +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" +) diff --git a/plugins/add_to_collection/options.py b/plugins/add_to_collection/options.py new file mode 100644 index 00000000..70f6bee4 --- /dev/null +++ b/plugins/add_to_collection/options.py @@ -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) diff --git a/plugins/add_to_collection/override_module.py b/plugins/add_to_collection/override_module.py new file mode 100644 index 00000000..7c579f38 --- /dev/null +++ b/plugins/add_to_collection/override_module.py @@ -0,0 +1,11 @@ +from contextlib import contextmanager +from typing import Generator + + +@contextmanager +def override_module(obj: object) -> Generator[None, None, None]: + # picard expects hooks to be defined at module level + module = obj.__module__ + obj.__module__ = ".".join(module.split(".")[:-1]) + yield + obj.__module__ = module diff --git a/plugins/add_to_collection/post_save_processor.py b/plugins/add_to_collection/post_save_processor.py new file mode 100644 index 00000000..291f5ef3 --- /dev/null +++ b/plugins/add_to_collection/post_save_processor.py @@ -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("cannot find collection ID setting") + 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 and 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) diff --git a/plugins/add_to_collection/settings.py b/plugins/add_to_collection/settings.py new file mode 100644 index 00000000..99a13c0f --- /dev/null +++ b/plugins/add_to_collection/settings.py @@ -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 diff --git a/plugins/add_to_collection/ui_add_to_collection_options.py b/plugins/add_to_collection/ui_add_to_collection_options.py new file mode 100644 index 00000000..e5fa6ab5 --- /dev/null +++ b/plugins/add_to_collection/ui_add_to_collection_options.py @@ -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:")) diff --git a/test/plugin_test_case.py b/test/plugin_test_case.py new file mode 100644 index 00000000..70b592b0 --- /dev/null +++ b/test/plugin_test_case.py @@ -0,0 +1,177 @@ +# -*- 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 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 +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] diff --git a/test/test_add_to_collection.py b/test/test_add_to_collection.py new file mode 100644 index 00000000..13ad0407 --- /dev/null +++ b/test/test_add_to_collection.py @@ -0,0 +1,138 @@ +import os +from itertools import chain +from test.plugin_test_case import PluginTestCase +from typing import Union +from unittest.mock import ANY, patch + +from picard.collection import Collection, user_collections +from picard.file import File +from picard.file import _file_post_save_processors as post_save_processors +from picard.ui.options import _pages as option_pages + + +class TestAddToCollection(PluginTestCase): + SAVE_FILE_SETTINGS = { + "dont_write_tags": True, + "rename_files": False, + "move_files": False, + "delete_empty_dirs": False, + "save_images_to_files": False, + "clear_existing_tags": False, + "compare_ignore_tags": [], + } + + def install_plugin(self) -> None: + self.plugin = self._test_plugin_install( + "Add to Collection", "add_to_collection" + ) + + def create_file(self, file_name: str, album_id: Union[str, None] = None) -> File: + file_path = os.path.join(self.tmp_directory, file_name) + file = File(file_path) + if album_id: + file.metadata["musicbrainz_albumid"] = album_id + self.tagger.files[file_path] = file + return file + + def tearDown(self) -> None: + user_collections.clear() + return super().tearDown() + + def test_hooks_installed(self) -> None: + self.install_plugin() + + self.assertIn( + self.plugin.post_save_processor.post_save_processor, + list(chain.from_iterable(post_save_processors.functions.values())), + ) + + self.assertIn( + self.plugin.options.AddToCollectionOptionsPage, list(option_pages) + ) + + def test_file_save(self) -> None: + self.install_plugin() + + self.set_config_values( + setting={ + **self.SAVE_FILE_SETTINGS, + "add_to_collection_id": "test_collection_id", + } + ) + + file = self.create_file(file_name="test.mp3", album_id="test_album_id") + user_collections["test_collection_id"] = Collection( + collection_id="test_collection_id", name="Test Collection", size=0 + ) + with patch("picard.collection.Collection.add_releases") as add_releases: + file.save() + add_releases.assert_called_with(set(["test_album_id"]), callback=ANY) + + def test_two_files_save(self) -> None: + self.install_plugin() + + self.set_config_values( + setting={ + **self.SAVE_FILE_SETTINGS, + "add_to_collection_id": "test_collection_id", + } + ) + + collection = Collection( + collection_id="test_collection_id", name="Test Collection", size=0 + ) + user_collections["test_collection_id"] = collection + with patch( + "picard.collection.Collection.add_releases", + side_effect=lambda ids, callback: collection.releases.update(ids), + ) as add_releases: + for i in range(2): + file = self.create_file( + file_name=f"test{i}.mp3", album_id="test_album_id" + ) + file.save() + # only added once + add_releases.assert_called_once_with(set(["test_album_id"]), callback=ANY) + + def test_no_collection_id_setting(self) -> None: + self.install_plugin() + + self.set_config_values(setting=self.SAVE_FILE_SETTINGS) + + file = self.create_file(file_name="test.mp3", album_id="test_album_id") + user_collections["test_collection_id"] = Collection( + collection_id="test_collection_id", name="Test Collection", size=0 + ) + with patch("picard.collection.Collection.add_releases") as add_releases: + file.save() + add_releases.assert_not_called() + + def test_no_user_collection(self) -> None: + self.install_plugin() + + self.set_config_values( + setting={ + **self.SAVE_FILE_SETTINGS, + "add_to_collection_id": "test_collection_id", + } + ) + + file = self.create_file(file_name="test.mp3", album_id="test_album_id") + with patch("picard.collection.Collection.add_releases") as add_releases: + file.save() + add_releases.assert_not_called() + + def test_no_release(self) -> None: + self.install_plugin() + + self.set_config_values( + setting={ + **self.SAVE_FILE_SETTINGS, + "add_to_collection_id": "test_collection_id", + } + ) + + file = self.create_file(file_name="test.mp3") + with patch("picard.collection.Collection.add_releases") as add_releases: + file.save() + add_releases.assert_not_called()