Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PICARD-2743: Add support for custom post tagging actions #374

Merged
merged 6 commits into from
Mar 18, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 35 additions & 15 deletions plugins/post_tagging_actions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
PLUGIN_API_VERSIONS = ["2.10", "2.11"]
PLUGIN_LICENSE = "GPL-2.0"
PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html"
PLUGIN_USER_GUIDE_URL = "https://github.com/twodoorcoupe/picard-plugins/tree/user_guides/user_guides/post_tagging_actions/guide.md"
PLUGIN_USER_GUIDE_URL = "https://github.com/metabrainz/picard-plugins/tree/2.0/plugins/post_tagging_actions/docs/guide.md"

from picard.album import Album
from picard.track import Track
Expand All @@ -46,14 +46,14 @@
from os import path
import re
import shlex
import subprocess
import subprocess # nosec B404

# Additional special variables.
TRACK_SPECIAL_VARIABLES = {
phw marked this conversation as resolved.
Show resolved Hide resolved
"filepath": lambda file: file,
"folderpath": lambda file: path.dirname(file),
"folderpath": lambda file: path.dirname(file), # pylint: disable=unnecessary-lambda
"filename": lambda file: path.splitext(path.basename(file))[0],
"filename_ext": lambda file: path.basename(file),
"filename_ext": lambda file: path.basename(file), # pylint: disable=unnecessary-lambda
"directory": lambda file: path.basename(path.dirname(file))
}
ALBUM_SPECIAL_VARIABLES = {
Expand All @@ -67,12 +67,14 @@

# Settings.
CANCEL = "pta_cancel"
MAX_WORKERS = "pta_max_workers"
OPTIONS = ["pta_command", "pta_wait_for_exit", "pta_execute_for_tracks", "pta_refresh_tags"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this be better as an immutable set (i.e. curly brackets) like the ALBUM_SPECIAL_VARIABLES above?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The order of the options in this list is the same as the order of the columns of the table in the options page. I use the order when saving or loading the options. This way I can just iterate over the columns without having to explicitly save or load the values in each column.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If set doesn't preserve order, this is a sound reason.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But it could be a tuple :)


Options = namedtuple("Options", ("variables", *[option[4:] for option in OPTIONS]))
Action = namedtuple("Action", ("commands", "album", "options"))
PriorityAction = namedtuple("PriorityAction", ("priority", "counter", "action"))
action_queue = PriorityQueue()
variables_pattern = re.compile(r'%.*?%')


class ActionLoader:
Expand All @@ -91,9 +93,8 @@ def __init__(self):
def _create_options(self, command, *other_options):
"""Finds the variables in the command and adds the options to the action options list.
"""
variables = [variable[1:-1] for variable in re.findall(r'%.*?%', command)]
variables = [parser.normalize_tagname(variable) for variable in variables]
command = re.sub(r'%.*?%', '{}', command)
variables = [parser.normalize_tagname(variable[1:-1]) for variable in variables_pattern.findall(command)]
command = variables_pattern.sub('{}', command)
options = Options(variables, command, *other_options)
self.action_options.append(options)

Expand Down Expand Up @@ -149,10 +150,10 @@ def load_actions(self):
This gets called when the plugin is loaded or when the user saves the options.
"""
self.action_options = []
loaded_options = zip(*[config.setting[name] for name in OPTIONS])
for options in loaded_options:
command = options[0]
other_options = [eval(option) for option in options[1:]]
option_tuples = zip(*[config.setting[name] for name in OPTIONS])
for option_tuple in option_tuples:
command = option_tuple[0]
other_options = [eval(option) for option in option_tuple[1:]] # nosec B307
self._create_options(command, *other_options)


Expand All @@ -166,7 +167,7 @@ class ActionRunner:
"""

def __init__(self):
self.action_thread_pool = futures.ThreadPoolExecutor()
self.action_thread_pool = futures.ThreadPoolExecutor(config.setting[MAX_WORKERS])
self.refresh_tags_pool = futures.ThreadPoolExecutor(1)
self.worker = Thread(target = self._execute)
self.worker.start()
Expand All @@ -186,7 +187,12 @@ def _refresh_tags(self, future_objects, album):
def _run_process(self, command):
"""Runs the process and waits for it to finish.
"""
process = subprocess.Popen(command, text = True, stdout = subprocess.PIPE, stderr = subprocess.PIPE)
process = subprocess.Popen(
command,
text = True,
stdout = subprocess.PIPE,
stderr = subprocess.PIPE
) # nosec B603
answer = process.communicate()
if answer[0]:
log.info("Action output:\n%s", answer[0])
Expand All @@ -201,12 +207,17 @@ def _execute(self):
"""
while True:
priority_action = action_queue.get()
QObject.tagger.window.set_statusbar_message(
N_("Post Tagging Actions: number of pending requests is %(pending_requests)d"),
{"pending_requests": action_queue.qsize()},
timeout = 3000
)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wasn't what I had in mind (which was to increment/decrement Picard's existing outstanding requests value), and I have no idea whether it will cause any issues (threading, excessive overhead etc.).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, sorry for misinterpreting. I think that value is used for webservice requests. Would it make sense to use it also for pending actions? For example, if I have some action running in the background and I load a new album, then the number of pending requests would include both?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its not a problem - you got the overall intent (that the user needs some sort of indicator about progress).

Yes - that value is intended for web-service requests. Maybe we need another similar indicator for local requests that can also be used for acoustid scans or volume levelling scans or re-encoding. Or perhaps you could look see what the which indicator is used by the native acoustid scan code and use the same indicator?

Copy link
Contributor Author

@twodoorcoupe twodoorcoupe Feb 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The acoustid scans use the Pending Files indicator right next to the Pending Requests one. I could make it so that the files being "used" by an action are put in the pending state, meaning the counter is updated and the tracks are greyed out until the action finishes. This way, however, Pending Files would indicate the number of files being "used" by an action, rather than the number of actions that still need to be completed. I don't know if this is what you meant.
In the meantime, I made the other changes you suggested.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cannot be certain because I haven't tried to use the plugin, but this description seems to make sense.

if priority_action.priority == -1:
break
next_action = priority_action.action
commands = next_action.commands
future_objects = {self.action_thread_pool.submit(self._run_process, command) for command in commands}

if next_action.options.wait_for_exit:
futures.wait(future_objects, return_when = futures.ALL_COMPLETED)
if next_action.options.refresh_tags:
Expand Down Expand Up @@ -259,7 +270,11 @@ class PostTaggingActionsOptions(OptionsPage):
PARENT = "plugins"

action_options = [config.ListOption("setting", name, []) for name in OPTIONS]
options = [config.BoolOption("setting", CANCEL, True), *action_options]
options = [
config.BoolOption("setting", CANCEL, True),
config.IntOption("setting", MAX_WORKERS, 4),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why 4?

Shouldn't this at least be based on the number of cores/hyperthreads that the CPU has?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made it so that the default value is the same as the one used by futures module.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds as good a default as we can get given that the tasks for a file could be heavy (reencoding the file) or light (doing some sort of web lookup), and the number of parallel tasks that can be supported would be very different for these two situations.

*action_options
]

def __init__(self, parent = None):
super(PostTaggingActionsOptions, self).__init__(parent)
Expand Down Expand Up @@ -350,6 +365,7 @@ def load(self):
widget = QtWidgets.QTableWidgetItem(values[column])
self.ui.table.setItem(row, column, widget)
self.ui.cancel.setChecked(config.setting[CANCEL])
self.ui.max_workers.setValue(config.setting[MAX_WORKERS])

def save(self):
"""Saves the actions table items in the settings.
Expand All @@ -362,6 +378,7 @@ def save(self):
settings[column].append(setting)
config.setting[OPTIONS[column]] = settings[column]
config.setting[CANCEL] = self.ui.cancel.isChecked()
config.setting[MAX_WORKERS] = self.ui.max_workers.value()
action_loader.load_actions()


Expand All @@ -370,4 +387,7 @@ def save(self):
register_album_action(ExecuteAlbumActions())
register_track_action(ExecuteTrackActions())
register_options_page(PostTaggingActionsOptions)

# This is used to register functions that run when the application is being closed.
# action_runner.stop makes the background threads stop.
QObject.tagger.register_cleanup(action_runner.stop)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what this register_cleanup line is for or why it is an initialisation statement.

Can you please annotate this with a comment explaining it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be moved into the initialisation of the ActionRunner object?

52 changes: 52 additions & 0 deletions plugins/post_tagging_actions/docs/guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Post Tagging Actions
This plugin lets you set up actions that run with a context menu click.
An action consists in a command line executed for each album or each track along with a few options to tweak the behaviour. This can be used to run external programs and pass some variables to it. Environment variables do not work.

To run the actions,
- First move the albums or tracks you are interested in to the right hand pane.
- Then highlight all the items you want the actions to run for.
- Right click, go to plugins, then click "Run Actions for highlighted albums/tracks".
### Adding an action
In the options page, you will find "Post Tagging Actions" under "Plguins". You will be greeted by this:
zas marked this conversation as resolved.
Show resolved Hide resolved

![options](options.png)

1. Enter the command to run in the text box, choose your options, then click "Add action".
2. You can click on "Add file" to search for a file and add its path to the text box.
3. Once you add an action, it will appear in the table at the bottom of the page. You can reorder actions with the arrows above the table.
## Options
- `Wait for process to finish` will make the next command in the queue execute only after this one has finished.
- `Refresh tags after process finishes` will reload the files once the command finishes. This is useful when an external program changes files' tags.
- `Execute for albums/tracks` lets you choose whether the command will be executed once for each track or each album highlighted.

The order of the actions in the table represents the order of execution: the top most action will be executed first.
## Variables
You can use variables in the commands just like in scripting. For example:
```
python /path/script.py --album %album% --artist %albumartist%
```
The variables `%album%` and `%albumartist%` will be replaced with their value for each album.

For actions that execute once per album, only variables in the album's metadata can be used. Same thing for actions that execute once per track, only variables in the track's metadata can be used.
### Special variables
There are also extra variables that can be used.

The following are used to get files' information:
- `%filepath%`: The full path of the file.
- `%folderpath%`: The path of the folder in which the file is located.
- `%filename%`: The name of the file, without the extension.
- `%filename_ext%`: The name of the file, with the extension.
- `%directory%`: The name of the folder in which the file is located.

When these are used with album actions, the first file found is considered. For example, if you keep all tracks in the same folder, using `%folderpath%` will give you the path to that folder.

The following apply to each album:
- `%gen_num_matched_tracks%`: The number of tracks which have a matching file.
- `%gen_num_unmatched_tracks%`: The number of tracks without any matching files.
- `%gen_num_total_files%`: The number of files added.
- `%gen_num_unsaed_files%`: The number of files that have unsaved changes.
- `%is_complete%`: "True" if every track is matched, "False" otherwise. (Shown with a gold disc in Picard)
- `%is_modified%`: "True" if the album has some changes to apply, "False" otherwise. (Shown with a red star on the disc)

When these are used with track actions, the album to which the track belongs to is considered.

Binary file added plugins/post_tagging_actions/docs/options.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 23 additions & 3 deletions plugins/post_tagging_actions/options_post_tagging_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# run again. Do not edit this file unless you know what you are doing.


from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5 import QtCore, QtWidgets


class Ui_PostTaggingActions(object):
Expand All @@ -23,7 +23,7 @@ def setupUi(self, PostTaggingActions):
self.scrollArea.setWidgetResizable(True)
self.scrollArea.setObjectName("scrollArea")
self.scrollAreaWidgetContents = QtWidgets.QWidget()
self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 606, 451))
self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, -70, 606, 502))
self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents")
self.vboxlayout = QtWidgets.QVBoxLayout(self.scrollAreaWidgetContents)
self.vboxlayout.setObjectName("vboxlayout")
Expand Down Expand Up @@ -130,6 +130,24 @@ def setupUi(self, PostTaggingActions):
self.cancel = QtWidgets.QCheckBox(self.frame)
self.cancel.setObjectName("cancel")
self.verticalLayout_2.addWidget(self.cancel)
self.widget_4 = QtWidgets.QWidget(self.frame)
self.widget_4.setObjectName("widget_4")
self.horizontalLayout = QtWidgets.QHBoxLayout(self.widget_4)
self.horizontalLayout.setObjectName("horizontalLayout")
self.max_workers = QtWidgets.QSpinBox(self.widget_4)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.max_workers.sizePolicy().hasHeightForWidth())
self.max_workers.setSizePolicy(sizePolicy)
self.max_workers.setMinimum(1)
self.max_workers.setMaximum(64)
self.max_workers.setObjectName("max_workers")
self.horizontalLayout.addWidget(self.max_workers)
self.label_2 = QtWidgets.QLabel(self.widget_4)
self.label_2.setObjectName("label_2")
self.horizontalLayout.addWidget(self.label_2)
self.verticalLayout_2.addWidget(self.widget_4)
self.label = QtWidgets.QLabel(self.frame)
self.label.setOpenExternalLinks(True)
self.label.setObjectName("label")
Expand Down Expand Up @@ -172,4 +190,6 @@ def retranslateUi(self, PostTaggingActions):
item.setText(_translate("PostTaggingActions", " Refresh tags "))
self.cancel.setToolTip(_translate("PostTaggingActions", "<html><head/><body><p>If <span style=\" font-weight:700;\">not </span>checked, when Picard is closed, it will wait for the actions to finish in the background.</p></body></html>"))
self.cancel.setText(_translate("PostTaggingActions", "Cancel actions in the queue when Picard is closed"))
self.label.setText(_translate("PostTaggingActions", "<html><head/><body><p>Hover over each item to know more, or take a peek at the user guide <a href=\"https://github.com/twodoorcoupe/picard-plugins/tree/user_guides/user_guides/post_tagging_actions/guide.md\"><span style=\" text-decoration: underline; color:#3584e4;\">here.</span></a></p></body></html>"))
self.label_2.setToolTip(_translate("PostTaggingActions", "Sets the number of background threads executing the actions"))
self.label_2.setText(_translate("PostTaggingActions", " Maximum number of worker threads (Requires Picard restart)"))
self.label.setText(_translate("PostTaggingActions", "<html><head/><body><p>Hover over each item to know more, or take a peek at the user guide <a href=\"https://github.com/metabrainz/picard-plugins/tree/2.0/plugins/post_tagging_actions/docs/guide.md\"><span style=\" text-decoration: underline; color:#3584e4;\">here.</span></a></p></body></html>"))
38 changes: 35 additions & 3 deletions plugins/post_tagging_actions/options_post_tagging_actions.ui
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<y>-70</y>
<width>606</width>
<height>451</height>
<height>502</height>
</rect>
</property>
<layout class="QVBoxLayout">
Expand Down Expand Up @@ -248,10 +248,42 @@
</property>
</widget>
</item>
<item>
<widget class="QWidget" name="widget_4" native="true">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QSpinBox" name="max_workers">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>64</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_2">
<property name="toolTip">
<string>Sets the number of background threads executing the actions</string>
</property>
<property name="text">
<string> Maximum number of worker threads (Requires Picard restart)</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Hover over each item to know more, or take a peek at the user guide &lt;a href=&quot;https://github.com/twodoorcoupe/picard-plugins/tree/user_guides/user_guides/post_tagging_actions/guide.md&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#3584e4;&quot;&gt;here.&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Hover over each item to know more, or take a peek at the user guide &lt;a href=&quot;https://github.com/metabrainz/picard-plugins/tree/2.0/plugins/post_tagging_actions/docs/guide.md&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#3584e4;&quot;&gt;here.&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="openExternalLinks">
<bool>true</bool>
Expand Down
Loading