Skip to content

Commit 73f00d6

Browse files
committed
1 parent cd557d4 commit 73f00d6

File tree

9 files changed

+211
-15
lines changed

9 files changed

+211
-15
lines changed

picard/const/appdirs.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,5 @@ def cache_folder():
4848

4949

5050
def plugin_folder():
51-
# FIXME: This really should be in QStandardPaths.StandardLocation.AppDataLocation instead,
52-
# but this is a breaking change that requires data migration
53-
return os.path.normpath(os.environ.get('PICARD_PLUGIN_DIR', os.path.join(config_folder(), 'plugins')))
51+
appdata_folder = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
52+
return os.path.normpath(os.environ.get('PICARD_PLUGIN_DIR', os.path.join(appdata_folder, 'plugins3')))

picard/plugin3/api.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
from picard.config import (
3232
Config,
3333
ConfigSection,
34-
config,
3534
get_config,
3635
)
3736
from picard.coverart.providers import (
@@ -77,7 +76,7 @@ def __init__(self, manifest: PluginManifest, tagger) -> None:
7776
self._manifest = manifest
7877
full_name = f'plugin.{self._manifest.module_name}'
7978
self._logger = getLogger(full_name)
80-
self._api_config = ConfigSection(config, full_name)
79+
self._api_config = ConfigSection(get_config(), full_name)
8180

8281
@property
8382
def web_service(self) -> WebService:

picard/plugin3/manager.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Picard, the next-generation MusicBrainz tagger
4+
#
5+
# Copyright (C) 2024 Philipp Wolfer
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License
9+
# as published by the Free Software Foundation; either version 2
10+
# of the License, or (at your option) any later version.
11+
#
12+
# This program is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU General Public License
18+
# along with this program; if not, write to the Free Software
19+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20+
21+
import os
22+
from typing import List
23+
24+
from picard import (
25+
api_versions_tuple,
26+
log,
27+
)
28+
from picard.plugin3.plugin import Plugin
29+
30+
31+
class PluginManager:
32+
"""Installs, loads and updates plugins from multiple plugin directories.
33+
"""
34+
_primary_plugin_dir: str = None
35+
_plugin_dirs: List[str] = []
36+
_plugins: List[Plugin] = []
37+
38+
def __init__(self, tagger):
39+
from picard.tagger import Tagger
40+
self._tagger: Tagger = tagger
41+
42+
def add_directory(self, dir_path: str, primary: bool = False) -> None:
43+
log.debug('Registering plugin directory %s', dir_path)
44+
dir_path = os.path.normpath(dir_path)
45+
46+
for entry in os.scandir(dir_path):
47+
if entry.is_dir():
48+
plugin = self._load_plugin(dir_path, entry.name)
49+
if plugin:
50+
log.debug('Found plugin %s in %s', plugin.plugin_name, plugin.local_path)
51+
self._plugins.append(plugin)
52+
53+
self._plugin_dirs.append(dir_path)
54+
if primary:
55+
self._primary_plugin_dir = dir_path
56+
57+
def init_plugins(self):
58+
# TODO: Only load and enable plugins enabled in configuration
59+
for plugin in self._plugins:
60+
try:
61+
plugin.load_module()
62+
plugin.enable(self._tagger)
63+
except Exception as ex:
64+
log.error('Failed initializing plugin %s from %s',
65+
plugin.plugin_name, plugin.local_path, exc_info=ex)
66+
67+
def _load_plugin(self, plugin_dir: str, plugin_name: str):
68+
plugin = Plugin(plugin_dir, plugin_name)
69+
try:
70+
plugin.read_manifest()
71+
# TODO: Check version compatibility
72+
compatible_versions = _compatible_api_versions(plugin.manifest.api_versions)
73+
if compatible_versions:
74+
return plugin
75+
else:
76+
log.warning('Plugin "%s" from "%s" is not compatible with this version of Picard.',
77+
plugin.plugin_name, plugin.local_path)
78+
except Exception as ex:
79+
log.warning('Could not read plugin manifest from %r',
80+
os.path.join(plugin_dir, plugin_name), exc_info=ex)
81+
return None
82+
83+
84+
def _compatible_api_versions(api_versions):
85+
return set(api_versions) & set(api_versions_tuple)

picard/plugin3/plugin.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Picard, the next-generation MusicBrainz tagger
4+
#
5+
# Copyright (C) 2024 Laurent Monin
6+
# Copyright (C) 2024 Philipp Wolfer
7+
#
8+
# This program is free software; you can redistribute it and/or
9+
# modify it under the terms of the GNU General Public License
10+
# as published by the Free Software Foundation; either version 2
11+
# of the License, or (at your option) any later version.
12+
#
13+
# This program is distributed in the hope that it will be useful,
14+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
# GNU General Public License for more details.
17+
#
18+
# You should have received a copy of the GNU General Public License
19+
# along with this program; if not, write to the Free Software
20+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
21+
22+
import importlib.util
23+
import os
24+
import sys
25+
26+
from picard.plugin3.api import PluginApi
27+
from picard.plugin3.manifest import PluginManifest
28+
29+
import pygit2
30+
31+
32+
class GitRemoteCallbacks(pygit2.RemoteCallbacks):
33+
34+
def transfer_progress(self, stats):
35+
print(f'{stats.indexed_objects}/{stats.total_objects}')
36+
37+
38+
class Plugin:
39+
local_path: str = None
40+
remote_url: str = None
41+
ref = None
42+
plugin_name: str = None
43+
module_name: str = None
44+
manifest: PluginManifest = None
45+
_module = None
46+
47+
def __init__(self, plugins_dir: str, plugin_name: str):
48+
if not os.path.exists(plugins_dir):
49+
os.makedirs(plugins_dir)
50+
self.plugins_dir = plugins_dir
51+
self.plugin_name = plugin_name
52+
self.module_name = f'picard.plugins.{self.plugin_name}'
53+
self.local_path = os.path.join(self.plugins_dir, self.plugin_name)
54+
55+
def sync(self, url: str = None, ref: str = None):
56+
"""Sync plugin source
57+
Use remote url or local path, and sets the repository to ref
58+
"""
59+
if url:
60+
self.remote_url = url
61+
if os.path.isdir(self.local_path):
62+
print(f'{self.local_path} exists, fetch changes')
63+
repo = pygit2.Repository(self.local_path)
64+
for remote in repo.remotes:
65+
remote.fetch(callbacks=GitRemoteCallbacks())
66+
else:
67+
print(f'Cloning {url} to {self.local_path}')
68+
repo = pygit2.clone_repository(url, self.local_path, callbacks=GitRemoteCallbacks())
69+
70+
print(list(repo.references))
71+
print(list(repo.branches))
72+
print(list(repo.remotes))
73+
74+
if ref:
75+
commit = repo.revparse_single(ref)
76+
else:
77+
commit = repo.revparse_single('HEAD')
78+
79+
print(commit)
80+
print(commit.message)
81+
# hard reset to passed ref or HEAD
82+
repo.reset(commit.id, pygit2.enums.ResetMode.HARD)
83+
84+
def read_manifest(self):
85+
"""Reads metadata for the plugin from the plugin's MANIFEST.toml
86+
"""
87+
manifest_path = os.path.join(self.local_path, 'MANIFEST.toml')
88+
with open(manifest_path, 'rb') as manifest_file:
89+
self.manifest = PluginManifest(self.plugin_name, manifest_file)
90+
91+
def load_module(self):
92+
"""Load corresponding module from source path"""
93+
module_file = os.path.join(self.local_path, '__init__.py')
94+
spec = importlib.util.spec_from_file_location(self.module_name, module_file)
95+
module = importlib.util.module_from_spec(spec)
96+
sys.modules[self.module_name] = module
97+
spec.loader.exec_module(module)
98+
self._module = module
99+
return module
100+
101+
def enable(self, tagger) -> None:
102+
"""Enable the plugin"""
103+
api = PluginApi(self.manifest, tagger)
104+
self._module.enable(api)
105+
106+
def disable(self) -> None:
107+
"""Disable the plugin"""
108+
self._module.disable()

picard/tagger.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
)
9696
from picard.config_upgrade import upgrade_config
9797
from picard.const import USER_DIR
98+
from picard.const.appdirs import plugin_folder
9899
from picard.const.sys import (
99100
IS_HAIKU,
100101
IS_MACOS,
@@ -111,10 +112,8 @@
111112
from picard.file import File
112113
from picard.formats import open_ as open_file
113114
from picard.i18n import setup_gettext
114-
from picard.pluginmanager import (
115-
PluginManager,
116-
plugin_dirs,
117-
)
115+
from picard.plugin3.manager import PluginManager
116+
from picard.pluginmanager import PluginManager as LegacyPluginManager
118117
from picard.releasegroup import ReleaseGroup
119118
from picard.track import (
120119
NonAlbumTrack,
@@ -354,10 +353,12 @@ def __init__(self, picard_args, localedir, autoupdate, pipe_handler=None):
354353
self.enable_menu_icons(config.setting['show_menu_icons'])
355354

356355
# Load plugins
357-
self.pluginmanager = PluginManager()
356+
# FIXME: Legacy, remove as soong no longer used by other code
357+
self.pluginmanager = LegacyPluginManager()
358+
359+
self.pluginmanager3 = PluginManager(self)
358360
if not self._no_plugins:
359-
for plugin_dir in plugin_dirs():
360-
self.pluginmanager.load_plugins_from_directory(plugin_dir)
361+
self.pluginmanager3.add_directory(plugin_folder(), primary=True)
361362

362363
self.browser_integration = BrowserIntegration()
363364
self.browser_integration.listen_port_changed.connect(self.listen_port_changed)
@@ -749,6 +750,7 @@ def _run_init(self):
749750
def run(self):
750751
self.update_browser_integration()
751752
self.window.show()
753+
self.pluginmanager3.init_plugins()
752754
QtCore.QTimer.singleShot(0, self._run_init)
753755
res = self.exec()
754756
self.exit()

requirements-macos-11.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
discid==1.2.0
22
Markdown==3.5.1
33
mutagen==1.47.0
4+
pygit2==1.14.1
45
PyJWT==2.8.0
56
pyobjc-core==10.1
67
pyobjc-framework-Cocoa==10.1

requirements-win.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
discid==1.2.0
22
Markdown==3.5.1
33
mutagen==1.47.0
4+
pygit2==1.14.1
45
PyJWT==2.8.0
56
PyQt6==6.6.1
67
PyQt6-Qt6==6.6.1

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
discid~=1.0
22
Markdown~=3.2
33
mutagen~=1.37
4+
pygit2~=1.14.1
45
PyJWT~=2.0
56
pyobjc-core>=6.2, <11; sys_platform == 'darwin'
67
pyobjc-framework-Cocoa>=6.2, <11; sys_platform == 'darwin'

test/test_const_appdirs.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,12 @@ def test_cache_folder_linux(self):
6767

6868
@unittest.skipUnless(IS_WIN, "Windows test")
6969
def test_plugin_folder_win(self):
70-
self.assert_home_path_equals('~/AppData/Local/MusicBrainz/Picard/plugins', plugin_folder())
70+
self.assert_home_path_equals('~/AppData/Roaming/MusicBrainz/Picard/plugins3', plugin_folder())
7171

7272
@unittest.skipUnless(IS_MACOS, "macOS test")
7373
def test_plugin_folder_macos(self):
74-
self.assert_home_path_equals('~/Library/Preferences/MusicBrainz/Picard/plugins', plugin_folder())
74+
self.assert_home_path_equals('~/Library/Application Support/MusicBrainz/Picard/plugins3', plugin_folder())
7575

7676
@unittest.skipUnless(IS_LINUX, "Linux test")
7777
def test_plugin_folder_linux(self):
78-
self.assert_home_path_equals('~/.config/MusicBrainz/Picard/plugins', plugin_folder())
78+
self.assert_home_path_equals('~/.local/share/MusicBrainz/Picard/plugins3', plugin_folder())

0 commit comments

Comments
 (0)