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-2526: Allow starting processing actions from the command line #2137

Merged
merged 26 commits into from
Aug 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
55c4f49
`ParseItemsToLoad` got prepared to parse commands
skelly37 Aug 11, 2022
dac5069
Method to get all the tracks from album pane for Tagger
skelly37 Aug 11, 2022
fd09816
Command handling introduced to Tagger
skelly37 Aug 11, 2022
542b68a
Commands are actually passed via command-line
skelly37 Aug 11, 2022
9d5884c
`picard -e remove` added
skelly37 Aug 15, 2022
1f13377
`picard -e remove_all` added`
skelly37 Aug 15, 2022
74ea98d
`picard -e lookup_cd` added
skelly37 Aug 15, 2022
54d334c
typo fix: argstring -> file
skelly37 Aug 15, 2022
0c70e66
List comprehension replaced with `list()`
skelly37 Aug 15, 2022
e0a41da
patched lookup_cd to be cross-platform
skelly37 Aug 15, 2022
93e3bd5
help for the `picard -e`
skelly37 Aug 16, 2022
9f7c18a
typo + start optimization
skelly37 Aug 16, 2022
6f2dafe
help added, log hidden from users
skelly37 Aug 16, 2022
6cdd617
command arguments better documented
skelly37 Aug 16, 2022
20a3992
Command-line args does not prevent picard from startup and are passed…
skelly37 Aug 25, 2022
31cfe55
behaviors of `picard -e` documented
skelly37 Aug 17, 2022
85f493f
Generate help for remote commands
zas Aug 17, 2022
ad9ee5d
`SAVE_COMPLETE` -> `SAVE_MODIFIED`
skelly37 Aug 19, 2022
7a5ec33
more clear command handling + log
skelly37 Aug 25, 2022
f06a2d4
`picard -e HELP` adapted to the previous patches
skelly37 Aug 25, 2022
4d0867f
`picard -e SAVE_MATCHED`
skelly37 Aug 25, 2022
9ec4720
`SAVE_MATCHED` saves only one file
skelly37 Aug 28, 2022
b420fd3
`tagger.get_all_file_objects` uses yield instead of adding lists
skelly37 Aug 28, 2022
5275a15
`tagger.get_all_file_objects()` -> `tagger.iter_all_files()`
skelly37 Aug 29, 2022
171eaba
`tagger.get_album_pane_tracks()` -> `tagger.iter_album_files()`
skelly37 Aug 29, 2022
bcc52cb
removed print leftover
skelly37 Aug 29, 2022
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
4 changes: 4 additions & 0 deletions picard/album.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
# Copyright (C) 2019 Joel Lintunen
# Copyright (C) 2020-2021 Gabriel Ferreira
# Copyright (C) 2021 Petit Minion
# Copyright (C) 2022 skelly37
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
Expand Down Expand Up @@ -161,6 +162,9 @@ def iterfiles(self, save=False):
if not save:
yield from self.unmatched_files.iterfiles()

def iter_correctly_matched_tracks(self):
yield from (track for track in self.tracks if track.num_linked_files == 1)

def enable_update_metadata_images(self, enabled):
self.update_metadata_images_enabled = enabled

Expand Down
249 changes: 236 additions & 13 deletions picard/tagger.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
import shutil
import signal
import sys
from textwrap import fill
from urllib.parse import urlparse

from PyQt5 import (
Expand Down Expand Up @@ -118,7 +119,6 @@
)
from picard.util import (
check_io_encoding,
decode_filename,
encode_filename,
is_hidden,
iter_files_from_objects,
Expand All @@ -132,6 +132,7 @@
versions,
webbrowser2,
)
from picard.util.cdrom import get_cdrom_drives
from picard.util.checkupdate import UpdateCheckManager
from picard.webservice import WebService
from picard.webservice.api_helpers import (
Expand Down Expand Up @@ -178,6 +179,7 @@ def plugin_dirs():
class ParseItemsToLoad:

def __init__(self, items):
self.commands = []
self.files = set()
self.mbids = set()
self.urls = set()
Expand All @@ -186,6 +188,9 @@ def __init__(self, items):
parsed = urlparse(item)
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":
# remove file:// prefix safely
self.files.add(item[7:])
Expand All @@ -195,9 +200,84 @@ def __init__(self, items):
# .path returns / before actual link
self.urls.add(parsed.path[1:])

def __bool__(self):
# needed to indicate whether Picard should be brought to the front
def non_executable_items(self):
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)

def __str__(self):
return "files: %r mbids: %r urls: %r commands: %r" % (self.files, self.mbids, self.urls, 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 = {
"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.",
),
"LOOKUP": RemoteCommand(
"handle_command_lookup",
help_text="Lookup all clusters in the cluster pane.",
),
"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.",
),
# due to the pipe protocol limitations
# we currently can handle only one file per `remove` command
"REMOVE": RemoteCommand(
"handle_command_remove",
help_text="Remove the file from Picard. Do nothing if no argument.",
help_args="[absolute path (1 file)]",
),
"REMOVE_ALL": RemoteCommand(
"handle_command_remove_all",
help_text="Remove all files from Picard.",
),
"REMOVE_SAVED": RemoteCommand(
"handle_command_remove_saved",
help_text="Remove all saved releases from the album pane.",
),
"SAVE_MATCHED": RemoteCommand(
"handle_command_save_matched",
help_text="Remove 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.",
),
}


class Tagger(QtWidgets.QApplication):

Expand All @@ -213,15 +293,16 @@ class Tagger(QtWidgets.QApplication):
_debug = False
_no_restore = False

def __init__(self, picard_args, localedir, autoupdate, pipe_handler=None):
def __init__(self, picard_args, localedir, autoupdate, pipe_handler=None, to_load=None):
zas marked this conversation as resolved.
Show resolved Hide resolved

super().__init__(sys.argv)
self.__class__.__instance = self
setup_config(self, picard_args.config_file)
config = get_config()
theme.setup(self)

self._cmdline_files = picard_args.FILE_OR_URL
self._to_load = to_load

self.autoupdate_enabled = autoupdate
self._no_restore = picard_args.no_restore
self._no_plugins = picard_args.no_plugins
Expand All @@ -242,6 +323,8 @@ def __init__(self, picard_args, localedir, autoupdate, pipe_handler=None):
self.pipe_handler.pipe_running = True
self.thread_pool.submit(self.pipe_server)

self._init_remote_commands()

# Provide a separate thread pool for operations that should not be
# delayed by longer background processing tasks, e.g. because the user
# expects instant feedback instead of waiting for a long list of
Expand Down Expand Up @@ -347,10 +430,12 @@ def pipe_server(self):
while self.pipe_handler.pipe_running:
messages = [x for x in self.pipe_handler.read_from_pipe() if x not in IGNORED]
if messages:
self.load_to_picard(messages)
log.debug("pipe messages: %r", messages)
thread.to_main(self.load_to_picard, messages)

def load_to_picard(self, items):
parsed_items = ParseItemsToLoad(items)
log.debug(str(parsed_items))

if parsed_items.files:
self.add_paths(parsed_items.files)
Expand All @@ -360,9 +445,105 @@ def load_to_picard(self, items):
for item in parsed_items.mbids | parsed_items.urls:
thread.to_main(file_lookup.mbid_lookup, item, None, None, False)

if parsed_items:
for command in parsed_items.commands:
self.handle_command(command)

if parsed_items.non_executable_items():
self.bring_tagger_front()

def iter_album_files(self):
for album in self.albums.values():
yield from album.iterfiles()

def iter_all_files(self):
yield from self.unclustered_files.files
yield from self.iter_album_files()
yield from self.clusters.iterfiles()

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_cluster(self, argstring):
self.cluster(self.unclustered_files.files)

def handle_command_fingerprint(self, argstring):
for album_name in self.albums:
self.analyze(self.albums[album_name].iterfiles())

def handle_command_lookup(self, argstring):
self.autotag(self.unclustered_files.files)
skelly37 marked this conversation as resolved.
Show resolved Hide resolved

def handle_command_lookup_cd(self, argstring):
disc = Disc()
devices = get_cdrom_drives()

if not argstring:
if devices:
device = devices[0]
else:
device = None
elif argstring in devices:
device = argstring
else:
thread.run_task(
partial(self._parse_disc_ripping_log, disc, argstring),
partial(self._lookup_disc, disc),
traceback=self._debug)
return

thread.run_task(
partial(disc.read, encode_filename(device)),
partial(self._lookup_disc, disc),
traceback=self._debug)

def handle_command_quit(self, argstring):
self.exit()
self.quit()

def handle_command_remove(self, argstring):
for file in self.iter_all_files():
if argstring == file.filename:
file.remove()
return

def handle_command_remove_all(self, argstring):
for file in self.iter_all_files():
file.remove()

def handle_command_remove_saved(self, argstring):
for track in self.iter_album_files():
if track.state == File.NORMAL:
track.remove()

def handle_command_save_matched(self, argstring):
for album in self.albums.values():
for track in album.iter_correctly_matched_tracks():
track.files[0].save()

def handle_command_save_modified(self, argstring):
for track in self.iter_album_files():
if track.state == File.CHANGED:
track.save()

def handle_command_scan(self, argstring):
self.analyze(self.unclustered_files.files)

def handle_command_show(self, argstring):
self.bring_tagger_front()

def handle_command_submit_fingerprints(self, argstring):
self.acoustidmanager.submit()

def enable_menu_icons(self, enabled):
self.setAttribute(QtCore.Qt.ApplicationAttribute.AA_DontShowIconsInMenus, not enabled)

Expand Down Expand Up @@ -468,9 +649,9 @@ def exit(self):
QtCore.QCoreApplication.processEvents()

def _run_init(self):
if self._cmdline_files:
self.load_to_picard([decode_filename(f) for f in self._cmdline_files])
del self._cmdline_files
if self._to_load:
self.load_to_picard(self._to_load)
del self._to_load

def run(self):
self.update_browser_integration()
Expand Down Expand Up @@ -1073,6 +1254,32 @@ def longversion():
print(versions.as_string())


def print_help_for_commands():
maxwidth = 80
helpcmd = []
for name in sorted(REMOTE_COMMANDS):
remcmd = REMOTE_COMMANDS[name]
s = " - %-34s %s" % (name + " " + remcmd.help_args, remcmd.help_text)
helpcmd.append(fill(s, width=maxwidth, subsequent_indent=' '*39))

print("""usage: picard -e [command] [arguments ...]
or picard -e [command 1] [arguments ...] -e [command 2] [arguments ...]

List of the commands available to execute in Picard from the command-line:
""")
print("\n".join(helpcmd))

def fmt(s):
print(fill(s, width=maxwidth, initial_indent=' '*2))

fmt("Commands are case insensitive.")
fmt("Picard will try to load all the positional arguments before processing commands.")
fmt("If there is no instance to pass the arguments to, Picard will start and process the commands after the"
"positional arguments are loaded, as mentioned above. Otherwise they will be handled by the running"
"Picard instance")
fmt("Arguments are optional, but some commands may require one or more arguments to actually do something.")


def process_picard_args():
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
Expand All @@ -1094,6 +1301,10 @@ def process_picard_args():
help="location of the configuration file (starts a stand-alone instance)")
parser.add_argument("-d", "--debug", action='store_true',
help="enable debug-level logging")
parser.add_argument("-e", "--exec", nargs="+", action='append',
help="send command (arguments can be entered after space) to a running instance "
"(use `-e help` for a list of the available commands)",
metavar="COMMAND")
parser.add_argument("-M", "--no-player", action='store_true',
help="disable built-in media player")
parser.add_argument("-N", "--no-restore", action='store_true',
Expand Down Expand Up @@ -1149,16 +1360,27 @@ def main(localedir=None, autoupdate=True):
picard_args.no_plugins,
picard_args.stand_alone_instance,
}

to_be_added = []
if not should_start:
to_be_added = []
for x in picard_args.FILE_OR_URL:
if not urlparse(x).netloc:
x = os.path.abspath(x)
to_be_added.append(x)

if picard_args.exec:
for e in picard_args.exec:
if "HELP" in [x.upper().strip() for x in e]:
print_help_for_commands()
sys.exit(0)
to_be_added.append("command://" + " ".join(e))

if to_be_added:
# note: log level isn't defined yet, it defaults to info, log.debug() would not work here
log.info("Sending messages to main instance: %r" % to_be_added)

try:
pipe_handler = pipe.Pipe(app_name=PICARD_APP_NAME, app_version=PICARD_FANCY_VERSION_STR, args=to_be_added)
pipe_handler = pipe.Pipe(app_name=PICARD_APP_NAME, app_version=PICARD_FANCY_VERSION_STR,
args=to_be_added)
should_start = pipe_handler.is_pipe_owner
except pipe.PipeErrorNoPermission as err:
log.error(err)
Expand All @@ -1167,6 +1389,7 @@ def main(localedir=None, autoupdate=True):

# pipe has sent its args to existing one, doesn't need to start
if not should_start:
log.debug("No need for spawning a new instance, exiting...")
# just a custom exit code to show that picard instance wasn't created
sys.exit(EXIT_NO_NEW_INSTANCE)
else:
Expand All @@ -1179,7 +1402,7 @@ def main(localedir=None, autoupdate=True):
except ImportError:
pass

tagger = Tagger(picard_args, localedir, autoupdate, pipe_handler=pipe_handler)
tagger = Tagger(picard_args, localedir, autoupdate, pipe_handler=pipe_handler, to_load=to_be_added)

# Initialize Qt default translations
translator = QtCore.QTranslator()
Expand Down