Execute external application

Dear community,

is it possible to call an external application from a Picard plugin?
I aim to apply ReplayGain tags with foobar2000 when files are saved.

Code draft:
PLUGIN_NAME = “ReplayGain with foobar2000”
PLUGIN_AUTHOR = “***”
PLUGIN_DESCRIPTION = “Apply ReplayGain on file saving with foobar2000”
PLUGIN_VERSION = ‘1.0’
PLUGIN_API_VERSIONS = [‘2.1’, ‘2.2’]
PLUGIN_LICENSE = “GPL-2.0-or-later”
PLUGIN_LICENSE_URL = “https://www.gnu.org/licenses/gpl-2.0.html

from picard import log
from picard.file import register_file_post_save_processor

import subprocess

def file_post_save_processor(file):
subprocess.run([(’“C:\Program Files (x86)\foobar2000\foobar2000.exe”’), ‘"/context_command:“ReplayGain/Scan per-file track gain” “path to media file.mp3”’])

register_file_post_save_processor(file_post_save_processor)

Sure, you can use the subprocess module for this as you suggested. The current replaygain plugin is an example for a plugin calling external tools, see https://github.com/metabrainz/picard-plugins/blob/3365403797d92f80aad85a9342e854fa6801e56a/plugins/replaygain/init.py#L82

Your very simple draft might already do for your needs. You get the full path to a file with file.filename.

@Horned_Reaper Or you can wait until I rewrite the existing ReplayGain plugin.

The plan (when I have time) is not to use an external program (like foobar2000) to do the replaygain calculations and write them to the file - which causes issues because Picard doesnt know the file has changed and does not see the changes and will overwrite them if you save the file. Instead the plan is to use ffmpeg to calculate the actual values and return them to Picard, which will put them into metadata waiting for a save.

But if you are planning to do a rewrite anyway, and are interested in following this approach, @outsidecontext and / or I can give you a steer on how to go about it. :slight_smile:

2 Likes

@outsidecontext, @Sophist, thank you very much for your valuable hints!

Another question: How to output error messages with Picard, beside the log output?
Is there a way to display a message box? Apparently the TkInter message box doesn’t work.

… One more question: Using the file_post_save_processor event and a subprocess, the Picard user interface doesn’t respond untill all file savings are complete.
Is there a way to avoid this?

Picard uses Qt5 for its GUI, via the PyQt5 Python bindings. You can use a QDialog to display a dialog, see also QDialog — PyQt Documentation v5.15.7 and QDialog Class | Qt Widgets 5.15.16 . I don’t think any of the official plugins uses this directly (most don’t really do any UI, except for sometimes having an options page), but there are some uses of it in Picard’s sources.

You can use run_task from picard.util.thread to run this in a separate thread. Again the replaygain plugin is an example of this, see https://github.com/metabrainz/picard-plugins/blob/3365403797d92f80aad85a9342e854fa6801e56a/plugins/replaygain/\_\_init__.py#L109

3 Likes

Thank you, appreciated!

I’d like to give the run_task a try and created a simple test program but it crashes on file saving.
Do you know why?

PLUGIN_NAME = "ReplayGain with r128gain"
PLUGIN_AUTHOR = "unknown"
PLUGIN_DESCRIPTION = "Add ReplayGain tag on file saving with the r128gain tool"
PLUGIN_VERSION = '1.0'
PLUGIN_API_VERSIONS = ['2.1', '2.2']
PLUGIN_LICENSE = "GPL-2.0-or-later"
PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html"

from picard import log
from picard.file import register_file_post_save_processor
from picard.util import thread

def file_post_save_processor(file):
  thread.run_task(add_replay_gain, file)

def add_replay_gain():
  log.debug("Test")

register_file_post_save_processor(file_post_save_processor)

Any idea why this simple application doesn’t work?
Which call parameters does the thread.run_task function expect?

thread.run_task expects two functions: One to do the actual work and which is run in a thread and one callback that runs on the main thread after the first has finished.

The worker function needs to be callable without arguments. If you have a function like add_replay_gain(file) which expects a file parameter you can use functools.partial to get a single callable function without arguments, e.g. partial(add_replay_gain, file).

The callback function expects two parameters, result and error.

The dummy module could look like this:

PLUGIN_NAME = "ReplayGain with r128gain"
PLUGIN_AUTHOR = "unknown"
PLUGIN_DESCRIPTION = "Add ReplayGain tag on file saving with the r128gain tool"
PLUGIN_VERSION = '1.0'
PLUGIN_API_VERSIONS = ['2.1', '2.2']
PLUGIN_LICENSE = "GPL-2.0-or-later"
PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html"

from functools import partial
from picard import log
from picard.file import register_file_post_save_processor
from picard.util import thread


def file_post_save_processor(file):
    thread.run_task(
        partial(add_replay_gain, file),  # the function actually doing the work, with parameter file
        finish_callback)


def add_replay_gain(file):
    log.debug("Test: %r", file.filename)
    return file  # The return value will be available in the callback as "result"


def finish_callback(result=None, error=None):
    if error:
        log.error(error)  # If add_replay_gain threw any exception we would get it here as "error"
        return
    
    file = result
    log.debug("Finished: %r", file.filename)


register_file_post_save_processor(file_post_save_processor)
6 Likes

Thank you very much for this excellent explanation, really appreciated, @outsidecontext!
It works like a charm! :slight_smile:

PLUGIN_NAME = "ReplayGain with r128gain"
PLUGIN_AUTHOR = "***"
PLUGIN_DESCRIPTION = "Add ReplayGain tags on file saving with the r128gain tool"
PLUGIN_VERSION = '1.0'
PLUGIN_API_VERSIONS = ['2.1', '2.2']
PLUGIN_LICENSE = "GPL-2.0-or-later"
PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html"

from picard import log
from picard.file import register_file_post_save_processor
from picard.util import thread
from functools import partial
from PyQt5 import QtCore
import subprocess

tagger = QtCore.QObject.tagger

def file_post_save_processor(file):
    thread.run_task(
        partial(add_replay_gain, file),  # The function which is actually doing the work, with the "file" parameter
        finish_callback)  # Runs on the main thread after the work function has finished

def add_replay_gain(file):
    tagger.window.set_statusbar_message('Adding ReplayGain tags to file "' + file.filename.replace("%", "%%") + '"...') # percent is used by this function for string formatting, hence this character needs to be escaped

    process = subprocess.Popen(["C:\\Program Files\\r128gain\\r128gain.exe", file.filename], stdout = subprocess.PIPE, stderr=subprocess.PIPE, shell = True, universal_newlines = True)
    stdout, stderr = process.communicate()

    log.debug(stderr)  # Since the r128gain tool outputs everything to the error channel the standard channel is put out

    if process.returncode > 0 or stderr.find("[Errno ") > -1:  # r128gain doesn't return an error code if a media file is not processed
      log.error("Error code: " + str(process.returncode))
      log.error("Error message: " + stderr)

    tagger.window.set_statusbar_message('Finished')

    return file  # The return value will be available in the callback as "result"

def finish_callback(result=None, error=None):
    if error:
        log.error("Unexpected error: " + str(error))  # If add_replay_gain threw any exception we would get it here as "error"
        return
    
    file = result

    log.debug("Finished: %r", file.filename)

register_file_post_save_processor(file_post_save_processor)
2 Likes