mirror of
https://github.com/fergalmoran/picard.git
synced 2026-01-07 00:54:14 +00:00
Merge pull request #2173 from rdswift/sequential_command_processing
PICARD-2546: Sequential processing of executable commands
This commit is contained in:
255
picard/tagger.py
255
picard/tagger.py
@@ -51,11 +51,11 @@ import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
from textwrap import fill
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -140,6 +140,10 @@ from picard.util.cdrom import (
|
||||
get_cdrom_drives,
|
||||
)
|
||||
from picard.util.checkupdate import UpdateCheckManager
|
||||
from picard.util.remotecommands import (
|
||||
REMOTE_COMMANDS,
|
||||
RemoteCommands,
|
||||
)
|
||||
from picard.webservice import WebService
|
||||
from picard.webservice.api_helpers import (
|
||||
AcoustIdAPIHelper,
|
||||
@@ -187,19 +191,16 @@ class ParseItemsToLoad:
|
||||
WINDOWS_DRIVE_TEST = re.compile(r"^[a-z]\:", re.IGNORECASE)
|
||||
|
||||
def __init__(self, items):
|
||||
self.commands = []
|
||||
self.files = set()
|
||||
self.mbids = set()
|
||||
self.urls = set()
|
||||
|
||||
for item in items:
|
||||
parsed = urlparse(item)
|
||||
log.debug(f"Parsed: {repr(parsed)}")
|
||||
if not parsed.scheme:
|
||||
self.files.add(item)
|
||||
elif parsed.scheme == "command":
|
||||
for x in item[10:].split(';'):
|
||||
self.commands.append(x.strip())
|
||||
elif parsed.scheme == "file":
|
||||
if parsed.scheme == "file":
|
||||
# remove file:// prefix safely
|
||||
self.files.add(item[7:])
|
||||
elif parsed.scheme == "mbid":
|
||||
@@ -217,104 +218,10 @@ class ParseItemsToLoad:
|
||||
return bool(self.files or self.mbids or self.urls)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.commands or self.files or self.mbids or self.urls)
|
||||
return bool(self.files or self.mbids or self.urls)
|
||||
|
||||
def __str__(self):
|
||||
return f"files: {repr(self.files)} mbids: f{repr(self.mbids)} urls: {repr(self.urls)} commands: {repr(self.commands)}"
|
||||
|
||||
|
||||
class RemoteCommand:
|
||||
def __init__(self, method_name, help_text=None, help_args=None):
|
||||
self.method_name = method_name
|
||||
self.help_text = help_text or ""
|
||||
self.help_args = help_args or ""
|
||||
|
||||
|
||||
REMOTE_COMMANDS = {
|
||||
"CLEAR_LOGS": RemoteCommand(
|
||||
"handle_command_clear_logs",
|
||||
help_text="Clear the Picard logs",
|
||||
),
|
||||
"CLUSTER": RemoteCommand(
|
||||
"handle_command_cluster",
|
||||
help_text="Cluster all files in the cluster pane.",
|
||||
),
|
||||
"FINGERPRINT": RemoteCommand(
|
||||
"handle_command_fingerprint",
|
||||
help_text="Calculate acoustic fingerprints for all (matched) files in the album pane.",
|
||||
),
|
||||
"FROM_FILE": RemoteCommand(
|
||||
"handle_command_from_file",
|
||||
help_text="Load command pipeline from a file.",
|
||||
help_args="[Absolute path to a file containing command pipeline]",
|
||||
),
|
||||
"LOAD": RemoteCommand(
|
||||
"handle_command_load",
|
||||
help_text="Load 1 or more files/MBIDs/URLs to Picard.",
|
||||
help_args="[supported MBID/URL or absolute path to a file]",
|
||||
),
|
||||
"LOOKUP": RemoteCommand(
|
||||
"handle_command_lookup",
|
||||
help_text="Lookup files in the clustering pane. Defaults to all files.",
|
||||
help_args="[clustered|unclustered|all]"
|
||||
),
|
||||
"LOOKUP_CD": RemoteCommand(
|
||||
"handle_command_lookup_cd",
|
||||
help_text="Read CD from the selected drive and lookup on MusicBrainz. "
|
||||
"Without argument, it defaults to the first (alphabetically) available disc drive",
|
||||
help_args="[device/log file]",
|
||||
),
|
||||
"QUIT": RemoteCommand(
|
||||
"handle_command_quit",
|
||||
help_text="Exit the running instance of Picard.",
|
||||
),
|
||||
"REMOVE": RemoteCommand(
|
||||
"handle_command_remove",
|
||||
help_text="Remove the file from Picard. Do nothing if no arguments provided.",
|
||||
help_args="[absolute path to 1 or more files]",
|
||||
),
|
||||
"REMOVE_ALL": RemoteCommand(
|
||||
"handle_command_remove_all",
|
||||
help_text="Remove all files from Picard.",
|
||||
),
|
||||
"REMOVE_EMPTY": RemoteCommand(
|
||||
"handle_command_remove_empty",
|
||||
help_text="Remove all empty clusters and albums.",
|
||||
),
|
||||
"REMOVE_SAVED": RemoteCommand(
|
||||
"handle_command_remove_saved",
|
||||
help_text="Remove all saved releases from the album pane.",
|
||||
),
|
||||
"REMOVE_UNCLUSTERED": RemoteCommand(
|
||||
"handle_command_remove_unclustered",
|
||||
help_text="Remove all unclustered files from the cluster pane.",
|
||||
),
|
||||
"SAVE_MATCHED": RemoteCommand(
|
||||
"handle_command_save_matched",
|
||||
help_text="Save all matched releases from the album pane."
|
||||
),
|
||||
"SAVE_MODIFIED": RemoteCommand(
|
||||
"handle_command_save_modified",
|
||||
help_text="Save all modified files from the album pane.",
|
||||
),
|
||||
"SCAN": RemoteCommand(
|
||||
"handle_command_scan",
|
||||
help_text="Scan all files in the cluster pane.",
|
||||
),
|
||||
"SHOW": RemoteCommand(
|
||||
"handle_command_show",
|
||||
help_text="Make the running instance the currently active window.",
|
||||
),
|
||||
"SUBMIT_FINGERPRINTS": RemoteCommand(
|
||||
"handle_command_submit_fingerprints",
|
||||
help_text="Submit outstanding acoustic fingerprints for all (matched) files in the album pane.",
|
||||
),
|
||||
"WRITE_LOGS": RemoteCommand(
|
||||
"handle_command_write_logs",
|
||||
help_text="Write Picard logs to a given path.",
|
||||
help_args="[absolute path to 1 file]",
|
||||
),
|
||||
}
|
||||
return f"files: {repr(self.files)} mbids: f{repr(self.mbids)} urls: {repr(self.urls)}"
|
||||
|
||||
|
||||
class Tagger(QtWidgets.QApplication):
|
||||
@@ -464,6 +371,8 @@ class Tagger(QtWidgets.QApplication):
|
||||
if self.autoupdate_enabled:
|
||||
self.updatecheckmanager = UpdateCheckManager(parent=self.window)
|
||||
|
||||
thread.run_task(self.run_commands, self._run_commands_finished)
|
||||
|
||||
@property
|
||||
def is_wayland(self):
|
||||
return self.platformName() == 'wayland'
|
||||
@@ -474,7 +383,7 @@ class Tagger(QtWidgets.QApplication):
|
||||
messages = [x for x in self.pipe_handler.read_from_pipe() if x not in IGNORED]
|
||||
if messages:
|
||||
log.debug("pipe messages: %r", messages)
|
||||
thread.to_main(self.load_to_picard, messages)
|
||||
self.load_to_picard(messages)
|
||||
|
||||
def _pipe_server_finished(self, result=None, error=None):
|
||||
if error:
|
||||
@@ -482,23 +391,69 @@ class Tagger(QtWidgets.QApplication):
|
||||
else:
|
||||
log.debug('pipe server stopped')
|
||||
|
||||
def load_to_picard(self, items):
|
||||
parsed_items = ParseItemsToLoad(items)
|
||||
log.debug(str(parsed_items))
|
||||
def run_commands(self):
|
||||
while not self.stopping:
|
||||
if not RemoteCommands.command_queue.empty() and not RemoteCommands.get_running():
|
||||
(cmd, arg) = RemoteCommands.command_queue.get()
|
||||
if cmd in self.commands:
|
||||
arg = arg.strip()
|
||||
log.info("Executing command: %s %r", cmd, arg)
|
||||
if cmd == 'QUIT':
|
||||
thread.to_main(self.commands[cmd], arg)
|
||||
else:
|
||||
RemoteCommands.set_running(True)
|
||||
original_priority_thread_count = self.priority_thread_pool.activeThreadCount()
|
||||
original_main_thread_count = self.thread_pool.activeThreadCount()
|
||||
original_save_thread_count = self.save_thread_pool.activeThreadCount()
|
||||
thread.to_main_with_blocking(self.commands[cmd], arg)
|
||||
|
||||
if parsed_items.files:
|
||||
self.add_paths(parsed_items.files)
|
||||
# Continue to show the task as running until all of the following
|
||||
# conditions are met:
|
||||
#
|
||||
# - main thread pool active tasks count is less than or equal to the
|
||||
# count at the start of task execution
|
||||
#
|
||||
# - priority thread pool active tasks count is less than or equal to
|
||||
# the count at the start of task execution
|
||||
#
|
||||
# - save thread pool active tasks count is less than or equal to the
|
||||
# count at the start of task execution
|
||||
#
|
||||
# - there are no pending webservice requests
|
||||
#
|
||||
# - there are no acoustid fingerprinting tasks running
|
||||
|
||||
if parsed_items.urls or parsed_items.mbids:
|
||||
file_lookup = self.get_file_lookup()
|
||||
for item in parsed_items.mbids | parsed_items.urls:
|
||||
thread.to_main(file_lookup.mbid_lookup, item, None, None, False)
|
||||
while True:
|
||||
time.sleep(0.1)
|
||||
if self.priority_thread_pool.activeThreadCount() > original_priority_thread_count or \
|
||||
self.thread_pool.activeThreadCount() > original_main_thread_count or \
|
||||
self.save_thread_pool.activeThreadCount() > original_save_thread_count or \
|
||||
self.webservice.num_pending_web_requests or \
|
||||
self._acoustid._running:
|
||||
continue
|
||||
break
|
||||
|
||||
for command in parsed_items.commands:
|
||||
self.handle_command(command)
|
||||
log.info("Completed command: %s %r", cmd, arg)
|
||||
RemoteCommands.set_running(False)
|
||||
|
||||
if parsed_items.non_executable_items():
|
||||
self.bring_tagger_front()
|
||||
else:
|
||||
log.error("Unknown command: %r", cmd)
|
||||
RemoteCommands.command_queue.task_done()
|
||||
time.sleep(.01)
|
||||
|
||||
def _run_commands_finished(self, result=None, error=None):
|
||||
if error:
|
||||
log.error('command executor failed: %r', error)
|
||||
else:
|
||||
log.debug('command executor stopped')
|
||||
|
||||
@staticmethod
|
||||
def load_to_picard(items):
|
||||
commands = []
|
||||
for item in items:
|
||||
parts = str(item).split(maxsplit=1)
|
||||
commands.append((parts[0], parts[1:] or ['']))
|
||||
RemoteCommands.parse_commands_to_queue(commands)
|
||||
|
||||
def iter_album_files(self):
|
||||
for album in self.albums.values():
|
||||
@@ -512,16 +467,6 @@ class Tagger(QtWidgets.QApplication):
|
||||
def _init_remote_commands(self):
|
||||
self.commands = {name: getattr(self, remcmd.method_name) for name, remcmd in REMOTE_COMMANDS.items()}
|
||||
|
||||
def handle_command(self, command):
|
||||
cmd, *args = command.split(' ', 1)
|
||||
argstring = next(iter(args), "")
|
||||
cmd = cmd.upper()
|
||||
log.debug("Executing command: %r", cmd)
|
||||
try:
|
||||
thread.to_main(self.commands[cmd], argstring.strip())
|
||||
except KeyError:
|
||||
log.error("Unknown command: %r", cmd)
|
||||
|
||||
def handle_command_clear_logs(self, argstring):
|
||||
self.window.log_dialog.clear()
|
||||
self.window.history_dialog.clear()
|
||||
@@ -533,35 +478,20 @@ class Tagger(QtWidgets.QApplication):
|
||||
for album_name in self.albums:
|
||||
self.analyze(self.albums[album_name].iterfiles())
|
||||
|
||||
@staticmethod
|
||||
def _read_lines_from_file(filepath):
|
||||
try:
|
||||
yield from (line.strip() for line in open(filepath).readlines())
|
||||
except Exception as e:
|
||||
log.error("Error reading command file '%s': %s" % (filepath, e))
|
||||
|
||||
@staticmethod
|
||||
def _parse_commands_from_lines(lines):
|
||||
for line in lines:
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
elements = shlex.split(line)
|
||||
if not elements:
|
||||
continue
|
||||
command_args = elements[1:] or ['']
|
||||
for element in command_args:
|
||||
yield f"command://{elements[0]} {element}"
|
||||
|
||||
def handle_command_from_file(self, argstring):
|
||||
for command in self._parse_commands_from_lines(self._read_lines_from_file(argstring)):
|
||||
self.load_to_picard((command,))
|
||||
RemoteCommands.get_commands_from_file(argstring)
|
||||
|
||||
def handle_command_load(self, argstring):
|
||||
if argstring.startswith("command://"):
|
||||
log.error("Cannot LOAD a command: %s", argstring)
|
||||
return
|
||||
parsed_items = ParseItemsToLoad([argstring])
|
||||
log.debug(str(parsed_items))
|
||||
|
||||
self.load_to_picard((argstring,))
|
||||
if parsed_items.files:
|
||||
self.add_paths(parsed_items.files)
|
||||
|
||||
if parsed_items.urls or parsed_items.mbids:
|
||||
file_lookup = self.get_file_lookup()
|
||||
for item in parsed_items.mbids | parsed_items.urls:
|
||||
file_lookup.mbid_lookup(item)
|
||||
|
||||
def handle_command_lookup(self, argstring):
|
||||
if argstring:
|
||||
@@ -602,6 +532,20 @@ class Tagger(QtWidgets.QApplication):
|
||||
partial(self._lookup_disc, disc),
|
||||
traceback=self._debug)
|
||||
|
||||
def handle_command_pause(self, argstring):
|
||||
arg = argstring.strip()
|
||||
if arg:
|
||||
try:
|
||||
delay = float(arg)
|
||||
if delay < 0:
|
||||
raise ValueError
|
||||
log.debug(f"Pausing command execution by {delay} seconds.")
|
||||
thread.run_task(partial(time.sleep, delay))
|
||||
except ValueError:
|
||||
log.error(f"Invalid command pause time specified: {repr(argstring)}")
|
||||
else:
|
||||
log.error("No command pause time specified.")
|
||||
|
||||
def handle_command_quit(self, argstring):
|
||||
self.exit()
|
||||
self.quit()
|
||||
@@ -617,7 +561,8 @@ class Tagger(QtWidgets.QApplication):
|
||||
self.remove([file])
|
||||
|
||||
def handle_command_remove_empty(self, argstring):
|
||||
for album in self.albums:
|
||||
_albums = [a for a in self.albums.values()]
|
||||
for album in _albums:
|
||||
if not any(album.iterfiles()):
|
||||
self.remove_album(album)
|
||||
|
||||
@@ -654,7 +599,7 @@ class Tagger(QtWidgets.QApplication):
|
||||
|
||||
def handle_command_write_logs(self, argstring):
|
||||
try:
|
||||
with open(argstring, 'w') as f:
|
||||
with open(argstring, 'w', encoding='utf8') as f:
|
||||
for x in self.window.log_dialog.log_tail.contents():
|
||||
f.write(f"{x.message}\n")
|
||||
except Exception as e:
|
||||
@@ -1435,14 +1380,14 @@ If a new instance will not be spawned files/directories will be passed to the ex
|
||||
for x in args.FILE_OR_URL:
|
||||
if not urlparse(x).netloc:
|
||||
x = os.path.abspath(x)
|
||||
args.processable.append(x)
|
||||
args.processable.append(f"LOAD {x}")
|
||||
|
||||
if args.exec:
|
||||
for e in args.exec:
|
||||
args.remote_commands_help = args.remote_commands_help or "HELP" in {x.upper().strip() for x in e}
|
||||
remote_command_args = e[1:] or ['']
|
||||
for arg in remote_command_args:
|
||||
args.processable.append(f"command://{e[0]} {arg}")
|
||||
args.processable.append(f"{e[0]} {arg}")
|
||||
|
||||
return args
|
||||
|
||||
|
||||
294
picard/util/remotecommands.py
Normal file
294
picard/util/remotecommands.py
Normal file
@@ -0,0 +1,294 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Picard, the next-generation MusicBrainz tagger
|
||||
#
|
||||
# Copyright (C) 2022 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 os
|
||||
import queue
|
||||
import shlex
|
||||
import threading
|
||||
|
||||
from picard import log
|
||||
|
||||
|
||||
class RemoteCommand:
|
||||
def __init__(self, method_name, help_text=None, help_args=None):
|
||||
self.method_name = method_name
|
||||
self.help_text = help_text or ""
|
||||
self.help_args = help_args or ""
|
||||
|
||||
|
||||
REMOTE_COMMANDS = {
|
||||
"CLEAR_LOGS": RemoteCommand(
|
||||
"handle_command_clear_logs",
|
||||
help_text="Clear the Picard logs",
|
||||
),
|
||||
"CLUSTER": RemoteCommand(
|
||||
"handle_command_cluster",
|
||||
help_text="Cluster all files in the cluster pane.",
|
||||
),
|
||||
"FINGERPRINT": RemoteCommand(
|
||||
"handle_command_fingerprint",
|
||||
help_text="Calculate acoustic fingerprints for all (matched) files in the album pane.",
|
||||
),
|
||||
"FROM_FILE": RemoteCommand(
|
||||
"handle_command_from_file",
|
||||
help_text="Load commands from a file.",
|
||||
help_args="[Path to a file containing commands]",
|
||||
),
|
||||
"LOAD": RemoteCommand(
|
||||
"handle_command_load",
|
||||
help_text="Load one or more files/MBIDs/URLs to Picard.",
|
||||
help_args="[supported MBID/URL or path to a file]",
|
||||
),
|
||||
"LOOKUP": RemoteCommand(
|
||||
"handle_command_lookup",
|
||||
help_text="Lookup files in the clustering pane. Defaults to all files.",
|
||||
help_args="[clustered|unclustered|all]"
|
||||
),
|
||||
"LOOKUP_CD": RemoteCommand(
|
||||
"handle_command_lookup_cd",
|
||||
help_text="Read CD from the selected drive and lookup on MusicBrainz. "
|
||||
"Without argument, it defaults to the first (alphabetically) available disc drive",
|
||||
help_args="[device/log file]",
|
||||
),
|
||||
"PAUSE": RemoteCommand(
|
||||
"handle_command_pause",
|
||||
help_text="Pause executable command processing.",
|
||||
help_args="[number of seconds to pause]",
|
||||
),
|
||||
"QUIT": RemoteCommand(
|
||||
"handle_command_quit",
|
||||
help_text="Exit the running instance of Picard.",
|
||||
),
|
||||
"REMOVE": RemoteCommand(
|
||||
"handle_command_remove",
|
||||
help_text="Remove the file from Picard. Do nothing if no arguments provided.",
|
||||
help_args="[absolute path to one or more files]",
|
||||
),
|
||||
"REMOVE_ALL": RemoteCommand(
|
||||
"handle_command_remove_all",
|
||||
help_text="Remove all files from Picard.",
|
||||
),
|
||||
"REMOVE_EMPTY": RemoteCommand(
|
||||
"handle_command_remove_empty",
|
||||
help_text="Remove all empty clusters and albums.",
|
||||
),
|
||||
"REMOVE_SAVED": RemoteCommand(
|
||||
"handle_command_remove_saved",
|
||||
help_text="Remove all saved files from the album pane.",
|
||||
),
|
||||
"REMOVE_UNCLUSTERED": RemoteCommand(
|
||||
"handle_command_remove_unclustered",
|
||||
help_text="Remove all unclustered files from the cluster pane.",
|
||||
),
|
||||
"SAVE_MATCHED": RemoteCommand(
|
||||
"handle_command_save_matched",
|
||||
help_text="Save all matched files from the album pane."
|
||||
),
|
||||
"SAVE_MODIFIED": RemoteCommand(
|
||||
"handle_command_save_modified",
|
||||
help_text="Save all modified files from the album pane.",
|
||||
),
|
||||
"SCAN": RemoteCommand(
|
||||
"handle_command_scan",
|
||||
help_text="Scan all files in the cluster pane.",
|
||||
),
|
||||
"SHOW": RemoteCommand(
|
||||
"handle_command_show",
|
||||
help_text="Make the running instance the currently active window.",
|
||||
),
|
||||
"SUBMIT_FINGERPRINTS": RemoteCommand(
|
||||
"handle_command_submit_fingerprints",
|
||||
help_text="Submit outstanding acoustic fingerprints for all (matched) files in the album pane.",
|
||||
),
|
||||
"WRITE_LOGS": RemoteCommand(
|
||||
"handle_command_write_logs",
|
||||
help_text="Write Picard logs to a given path.",
|
||||
help_args="[absolute path to one file]",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class RemoteCommands:
|
||||
"""Handler for remote commands processed from the command line using the '-e' option.
|
||||
"""
|
||||
# Collection of command files currently being parsed
|
||||
_command_files = set()
|
||||
|
||||
# Flag to indicate whether a 'QUIT' command has been queued
|
||||
_has_quit = False
|
||||
|
||||
# Flag to indicate whether a command is currently running
|
||||
_command_running = False
|
||||
|
||||
_lock = threading.Lock()
|
||||
command_queue = queue.Queue()
|
||||
|
||||
@classmethod
|
||||
def cmd_files_contains(cls, filepath: str):
|
||||
"""Check if the specified filepath is currently open for reading commands.
|
||||
|
||||
Args:
|
||||
filepath (str): File path to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the filepath is open for processing, otherwise False.
|
||||
"""
|
||||
with cls._lock:
|
||||
return filepath in cls._command_files
|
||||
|
||||
@classmethod
|
||||
def cmd_files_add(cls, filepath: str):
|
||||
"""Adds the specified filepath to the collection of files currently open
|
||||
for reading commands.
|
||||
|
||||
Args:
|
||||
filepath (str): File path to add.
|
||||
"""
|
||||
with cls._lock:
|
||||
cls._command_files.add(filepath)
|
||||
|
||||
@classmethod
|
||||
def cmd_files_remove(cls, filepath: str):
|
||||
"""Removes the specified filepath from the collection of files currently
|
||||
open for reading commands.
|
||||
|
||||
Args:
|
||||
filepath (str): File path to remove.
|
||||
"""
|
||||
with cls._lock:
|
||||
cls._command_files.discard(filepath)
|
||||
|
||||
@classmethod
|
||||
def has_quit(cls):
|
||||
"""Indicates whether a 'QUIT' command has been added to the command queue.
|
||||
|
||||
Returns:
|
||||
bool: True if a 'QUIT' command has been queued, otherwise False.
|
||||
"""
|
||||
with cls._lock:
|
||||
return cls._has_quit
|
||||
|
||||
@classmethod
|
||||
def set_quit(cls, value: bool):
|
||||
"""Sets the status of the 'has_quit()' flag.
|
||||
|
||||
Args:
|
||||
value (bool): Value to set for the 'has_quit()' flag.
|
||||
"""
|
||||
with cls._lock:
|
||||
cls._has_quit = value
|
||||
|
||||
@classmethod
|
||||
def get_running(cls):
|
||||
"""Indicates whether a command is currently set as active regardless of
|
||||
processing status.
|
||||
|
||||
Returns:
|
||||
bool: True if there is an active command, otherwise False.
|
||||
"""
|
||||
with cls._lock:
|
||||
return cls._command_running
|
||||
|
||||
@classmethod
|
||||
def set_running(cls, value: bool):
|
||||
"""Sets the status of the 'get_running()' flag.
|
||||
|
||||
Args:
|
||||
value (bool): Value to set for the 'get_running()' flag.
|
||||
"""
|
||||
with cls._lock:
|
||||
cls._command_running = value
|
||||
|
||||
@classmethod
|
||||
def parse_commands_to_queue(cls, commands):
|
||||
"""Parses the list of command tuples, and adds them to the command queue. If the command
|
||||
is 'FROM_FILE' then the commands will be read from the file recursively. Once a 'QUIT'
|
||||
command has been queued, all further commands will be ignored and not placed in the queue.
|
||||
|
||||
Args:
|
||||
commands (list): Command tuples in the form (command, [args]) to add to the queue.
|
||||
"""
|
||||
if cls.has_quit():
|
||||
# Don't queue any more commands after a QUIT command.
|
||||
return
|
||||
|
||||
for (cmd, cmdargs) in commands:
|
||||
cmd = cmd.upper()
|
||||
if cmd not in REMOTE_COMMANDS:
|
||||
log.error("Unknown command: %s", cmd)
|
||||
continue
|
||||
for cmd_arg in cmdargs or ['']:
|
||||
if cmd == 'FROM_FILE':
|
||||
cls.get_commands_from_file(cmd_arg)
|
||||
else:
|
||||
log.debug(f"Queueing command: {cmd} {repr(cmd_arg)}")
|
||||
cls.command_queue.put([cmd, cmd_arg])
|
||||
|
||||
# Set flag so as to not queue any more commands after a QUIT command.
|
||||
if cmd == 'QUIT':
|
||||
cls.set_quit(True)
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _read_commands_from_file(filepath: str):
|
||||
"""Reads the commands from the specified filepath.
|
||||
|
||||
Args:
|
||||
filepath (str): File to read.
|
||||
|
||||
Returns:
|
||||
list: Command tuples in the form (command, [args]).
|
||||
"""
|
||||
commands = []
|
||||
try:
|
||||
lines = open(filepath).readlines()
|
||||
except Exception as e:
|
||||
log.error("Error reading command file '%s': %s" % (filepath, e))
|
||||
return commands
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
elements = shlex.split(line)
|
||||
if not elements:
|
||||
continue
|
||||
command_args = elements[1:] or ['']
|
||||
commands.append((elements[0], command_args))
|
||||
return commands
|
||||
|
||||
@classmethod
|
||||
def get_commands_from_file(cls, filepath: str):
|
||||
"""Reads and parses the commands from the specified filepath and adds
|
||||
them to the command queue for processing.
|
||||
|
||||
Args:
|
||||
filepath (str): File to read.
|
||||
"""
|
||||
log.debug("Reading commands from: %r", filepath)
|
||||
if not os.path.exists(filepath):
|
||||
log.error("Missing command file: '%s'", filepath)
|
||||
return
|
||||
absfilepath = os.path.abspath(filepath)
|
||||
if cls.cmd_files_contains(absfilepath):
|
||||
log.warning("Circular command file reference ignored: '%s'", filepath)
|
||||
return
|
||||
cls.cmd_files_add(absfilepath)
|
||||
cls.parse_commands_to_queue(cls._read_commands_from_file(absfilepath))
|
||||
cls.cmd_files_remove(absfilepath)
|
||||
@@ -10,6 +10,7 @@
|
||||
# Copyright (C) 2017 Sophist-UK
|
||||
# Copyright (C) 2018 Vishal Choudhary
|
||||
# Copyright (C) 2020, 2022 Philipp Wolfer
|
||||
# Copyright (C) 2022 Bob Swift
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
@@ -27,6 +28,7 @@
|
||||
|
||||
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from PyQt5.QtCore import (
|
||||
@@ -69,7 +71,7 @@ class Runnable(QRunnable):
|
||||
to_main(self.next_func, result=result)
|
||||
|
||||
|
||||
def run_task(func, next_func, priority=0, thread_pool=None, traceback=True):
|
||||
def run_task(func, next_func=None, priority=0, thread_pool=None, traceback=True):
|
||||
"""Schedules func to be run on a separate thread
|
||||
|
||||
Args:
|
||||
@@ -84,6 +86,12 @@ def run_task(func, next_func, priority=0, thread_pool=None, traceback=True):
|
||||
Returns:
|
||||
An instance of concurrent.futures.Future
|
||||
"""
|
||||
def _no_operation(*args, **kwargs):
|
||||
return
|
||||
|
||||
if not next_func:
|
||||
next_func = _no_operation
|
||||
|
||||
if not thread_pool:
|
||||
thread_pool = QCoreApplication.instance().thread_pool
|
||||
thread_pool.start(Runnable(func, next_func, traceback), priority)
|
||||
@@ -92,3 +100,23 @@ def run_task(func, next_func, priority=0, thread_pool=None, traceback=True):
|
||||
def to_main(func, *args, **kwargs):
|
||||
QCoreApplication.postEvent(QCoreApplication.instance(),
|
||||
ProxyToMainEvent(func, *args, **kwargs))
|
||||
|
||||
|
||||
def to_main_with_blocking(func, *args, **kwargs):
|
||||
"""Executes a command as a user-defined event, and waits until the event has
|
||||
closed before returning. Note that any new threads started while processing
|
||||
the event will not be considered when releasing the blocking of the function.
|
||||
|
||||
Args:
|
||||
func: Function to run.
|
||||
"""
|
||||
_task = ProxyToMainEvent(func, *args, **kwargs)
|
||||
QCoreApplication.postEvent(QCoreApplication.instance(), _task)
|
||||
|
||||
while True:
|
||||
try:
|
||||
if not _task.isAccepted():
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
time.sleep(.01)
|
||||
|
||||
15
test/data/test-command-file-1.txt
Normal file
15
test/data/test-command-file-1.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
# should be split into 2 commands
|
||||
LOAD file1.mp3 file2.mp3
|
||||
|
||||
# should be added as one
|
||||
LOAD file3.mp3
|
||||
|
||||
# should be ignored because circular reference
|
||||
FROM_FILE test/data/test-command-file-1.txt
|
||||
|
||||
# should be ignored
|
||||
|
||||
|
||||
#commented command
|
||||
|
||||
FROM_FILE test/data/test-command-file-2.txt
|
||||
10
test/data/test-command-file-2.txt
Normal file
10
test/data/test-command-file-2.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
# should be ignored because missing
|
||||
FROM_FILE command_file.txt
|
||||
|
||||
CLUSTER
|
||||
FINGERPRINT
|
||||
LOOKUP unclustered
|
||||
QUIT
|
||||
|
||||
# should be ignored because after QUIT command
|
||||
LOOKUP clustered
|
||||
@@ -1,47 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Picard, the next-generation MusicBrainz tagger
|
||||
#
|
||||
# Copyright (C) 2022 skelly37
|
||||
# Copyright (C) 2022 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.
|
||||
|
||||
from test.picardtestcase import PicardTestCase
|
||||
|
||||
from picard.tagger import Tagger
|
||||
from picard.util.remotecommands import RemoteCommands
|
||||
|
||||
|
||||
class TestParsingFilesWithCommands(PicardTestCase):
|
||||
|
||||
MOCK_FILE_CONTENTS = (
|
||||
# should be split into 2 commands
|
||||
"LOAD file1.mp3 file2.mp3",
|
||||
# should be added as one
|
||||
"FROM_FILE file0.mp3",
|
||||
"CLUSTER",
|
||||
" FINGERPRINT "
|
||||
# should be ignored
|
||||
"",
|
||||
" ",
|
||||
"\n",
|
||||
"#commented command",
|
||||
)
|
||||
TEST_FILE = 'test/data/test-command-file-1.txt'
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.result = tuple(x for x in Tagger._parse_commands_from_lines(self.MOCK_FILE_CONTENTS))
|
||||
self.result = []
|
||||
RemoteCommands.set_quit(False)
|
||||
RemoteCommands.get_commands_from_file(self.TEST_FILE)
|
||||
while not RemoteCommands.command_queue.empty():
|
||||
(cmd, arg) = RemoteCommands.command_queue.get()
|
||||
self.result.append(f"{cmd} {arg}")
|
||||
RemoteCommands.command_queue.task_done()
|
||||
|
||||
def test_no_argument_command(self):
|
||||
self.assertIn("command://CLUSTER ", self.result)
|
||||
self.assertIn("CLUSTER ", self.result)
|
||||
|
||||
def test_no_argument_command_stripped_correctly(self):
|
||||
self.assertIn("command://FINGERPRINT ", self.result)
|
||||
self.assertIn("FINGERPRINT ", self.result)
|
||||
|
||||
def test_single_argument_command(self):
|
||||
self.assertIn("command://FROM_FILE file0.mp3", self.result)
|
||||
self.assertIn("LOAD file3.mp3", self.result)
|
||||
|
||||
def test_multiple_arguments_command(self):
|
||||
self.assertIn("command://LOAD file1.mp3", self.result)
|
||||
self.assertIn("command://LOAD file2.mp3", self.result)
|
||||
self.assertIn("LOAD file1.mp3", self.result)
|
||||
self.assertIn("LOAD file2.mp3", self.result)
|
||||
|
||||
def test_from_file_command_parsed(self):
|
||||
self.assertNotIn("FROM_FILE command_file.txt", self.result)
|
||||
self.assertNotIn("FROM_FILE test/data/test-command-file-1.txt", self.result)
|
||||
self.assertNotIn("FROM_FILE test/data/test-command-file-2.txt", self.result)
|
||||
|
||||
def test_noting_added_after_quit(self):
|
||||
self.assertNotIn("LOOKUP clustered", self.result)
|
||||
|
||||
def test_empty_lines(self):
|
||||
self.assertNotIn("command:// ", self.result)
|
||||
self.assertNotIn("command://", self.result)
|
||||
# 1 FROM_FILE
|
||||
# 2 LOADs
|
||||
self.assertEqual(len(self.result), 5)
|
||||
self.assertNotIn(" ", self.result)
|
||||
self.assertNotIn("", self.result)
|
||||
self.assertEqual(len(self.result), 7)
|
||||
|
||||
def test_commented_lines(self):
|
||||
self.assertNotIn("command://#commented command", self.result)
|
||||
self.assertNotIn("#commented command", self.result)
|
||||
|
||||
Reference in New Issue
Block a user