Files
picard/picard/ui/mainwindow.py

1930 lines
78 KiB
Python

# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2006-2008, 2011-2012, 2014 Lukáš Lalinský
# Copyright (C) 2007 Nikolai Prokoschenko
# Copyright (C) 2008 Gary van der Merwe
# Copyright (C) 2008 Robert Kaye
# Copyright (C) 2008 Will
# Copyright (C) 2008-2010, 2015, 2018-2022 Philipp Wolfer
# Copyright (C) 2009 Carlin Mangar
# Copyright (C) 2009 David Hilton
# Copyright (C) 2011-2012 Chad Wilson
# Copyright (C) 2011-2013, 2015-2017 Wieland Hoffmann
# Copyright (C) 2011-2014 Michael Wiencek
# Copyright (C) 2013-2014, 2017 Sophist-UK
# Copyright (C) 2013-2022 Laurent Monin
# Copyright (C) 2015 Ohm Patel
# Copyright (C) 2015 samithaj
# Copyright (C) 2016 Rahul Raturi
# Copyright (C) 2016 Simon Legner
# Copyright (C) 2016-2017 Sambhav Kothari
# Copyright (C) 2017 Antonio Larrosa
# Copyright (C) 2017 Frederik “Freso” S. Olesen
# Copyright (C) 2018 Kartik Ohri
# Copyright (C) 2018 Vishal Choudhary
# Copyright (C) 2018 virusMac
# Copyright (C) 2018, 2021 Bob Swift
# Copyright (C) 2019 Timur Enikeev
# Copyright (C) 2020-2021 Gabriel Ferreira
# Copyright (C) 2021 Petit Minion
#
# 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 collections import OrderedDict
from copy import deepcopy
import datetime
from functools import partial
import os.path
from PyQt5 import (
QtCore,
QtGui,
QtWidgets,
)
from picard import (
PICARD_APP_ID,
log,
)
from picard.album import Album
from picard.browser import addrelease
from picard.cluster import (
Cluster,
FileList,
)
from picard.config import (
BoolOption,
FloatOption,
IntOption,
Option,
SettingConfigSection,
TextOption,
get_config,
)
from picard.const import PROGRAM_UPDATE_LEVELS
from picard.const.sys import (
IS_MACOS,
IS_WIN,
)
from picard.file import File
from picard.formats import supported_formats
from picard.plugin import ExtensionPoint
from picard.script import get_file_naming_script_presets
from picard.track import Track
from picard.util import (
icontheme,
iter_files_from_objects,
iter_unique,
restore_method,
thread,
throttle,
webbrowser2,
)
from picard.util.cdrom import (
discid,
get_cdrom_drives,
)
from picard.ui import PreserveGeometry
from picard.ui.aboutdialog import AboutDialog
from picard.ui.coverartbox import CoverArtBox
from picard.ui.filebrowser import FileBrowser
from picard.ui.infodialog import (
AlbumInfoDialog,
ClusterInfoDialog,
FileInfoDialog,
TrackInfoDialog,
)
from picard.ui.infostatus import InfoStatus
from picard.ui.itemviews import (
BaseTreeView,
MainPanel,
)
from picard.ui.logview import (
HistoryView,
LogView,
)
from picard.ui.metadatabox import MetadataBox
from picard.ui.options.dialog import OptionsDialog
from picard.ui.passworddialog import (
PasswordDialog,
ProxyDialog,
)
from picard.ui.scripteditor import (
ScriptEditorDialog,
ScriptEditorExamples,
user_script_title,
)
from picard.ui.searchdialog.album import AlbumSearchDialog
from picard.ui.searchdialog.track import TrackSearchDialog
from picard.ui.statusindicator import DesktopStatusIndicator
from picard.ui.tagsfromfilenames import TagsFromFileNamesDialog
from picard.ui.util import (
MultiDirsSelectDialog,
find_starting_directory,
)
ui_init = ExtensionPoint(label='ui_init')
def register_ui_init(function):
ui_init.register(function.__module__, function)
class IgnoreSelectionContext:
"""Context manager for holding a boolean value, indicating whether selection changes are performed or not.
By default the context resolves to False. If entered it is True. This allows
to temporarily set a state on a block of code like:
ignore_changes = IgnoreSelectionContext()
# Initially ignore_changes is True
with ignore_changes:
# Perform some tasks with ignore_changes now being True
...
# ignore_changes is False again
"""
def __init__(self, onexit=None):
self._entered = 0
self._onexit = onexit
def __enter__(self):
self._entered += 1
def __exit__(self, type, value, tb):
self._entered -= 1
if self._onexit:
self._onexit()
def __bool__(self):
return self._entered > 0
class MainWindowActions:
_create_actions = []
@classmethod
def add(cls):
def decorator(fn):
cls._create_actions.append(fn)
return fn
return decorator
@classmethod
def create(cls, parent):
for create_action in cls._create_actions:
create_action(parent)
class MainWindow(QtWidgets.QMainWindow, PreserveGeometry):
defaultsize = QtCore.QSize(780, 560)
selection_updated = QtCore.pyqtSignal(object)
ready_for_display = QtCore.pyqtSignal()
options = [
Option("persist", "window_state", QtCore.QByteArray()),
BoolOption("persist", "window_maximized", False),
BoolOption("persist", "view_metadata_view", True),
BoolOption("persist", "view_cover_art", True),
BoolOption("persist", "view_toolbar", True),
BoolOption("persist", "view_file_browser", False),
TextOption("persist", "current_directory", ""),
FloatOption("persist", "mediaplayer_playback_rate", 1.0),
IntOption("persist", "mediaplayer_volume", 50),
]
def __init__(self, parent=None, disable_player=False):
super().__init__(parent)
self.__shown = False
self.selected_objects = []
self.ignore_selection_changes = IgnoreSelectionContext(self.update_selection)
self.toolbar = None
self.player = None
self.status_indicators = []
if DesktopStatusIndicator:
self.ready_for_display.connect(self._setup_desktop_status_indicator)
if not disable_player:
from picard.ui.playertoolbar import Player
player = Player(self)
if player.available:
self.player = player
self.player.error.connect(self._on_player_error)
self.script_editor_dialog = None
self.examples = None
self.check_and_repair_profiles()
self.setupUi()
def setupUi(self):
self.setWindowTitle(_("MusicBrainz Picard"))
icon = QtGui.QIcon()
for size in (16, 24, 32, 48, 128, 256):
icon.addFile(
":/images/{size}x{size}/{app_id}.png".format(
size=size, app_id=PICARD_APP_ID),
QtCore.QSize(size, size)
)
self.setWindowIcon(icon)
self.show_close_window = IS_MACOS
self.create_actions()
self.create_statusbar()
self.create_toolbar()
self.create_menus()
if IS_MACOS:
self.setUnifiedTitleAndToolBarOnMac(True)
main_layout = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical)
main_layout.setObjectName('main_window_bottom_splitter')
main_layout.setChildrenCollapsible(False)
main_layout.setContentsMargins(0, 0, 0, 0)
self.panel = MainPanel(self, main_layout)
self.panel.setObjectName('main_panel_splitter')
self.file_browser = FileBrowser(self.panel)
if not self.show_file_browser_action.isChecked():
self.file_browser.hide()
self.panel.insertWidget(0, self.file_browser)
self.log_dialog = LogView(self)
self.history_dialog = HistoryView(self)
self.metadata_box = MetadataBox(self)
self.cover_art_box = CoverArtBox(self)
metadata_view_layout = QtWidgets.QHBoxLayout()
metadata_view_layout.setContentsMargins(0, 0, 0, 0)
metadata_view_layout.setSpacing(0)
metadata_view_layout.addWidget(self.metadata_box, 1)
metadata_view_layout.addWidget(self.cover_art_box, 0)
self.metadata_view = QtWidgets.QWidget()
self.metadata_view.setLayout(metadata_view_layout)
self.show_metadata_view()
self.show_cover_art()
main_layout.addWidget(self.panel)
main_layout.addWidget(self.metadata_view)
self.setCentralWidget(main_layout)
# accessibility
self.set_tab_order()
for function in ui_init:
function(self)
def set_processing(self, processing=True):
self.panel.set_processing(processing)
def set_sorting(self, sorting=True):
self.panel.set_sorting(sorting)
def keyPressEvent(self, event):
# On macOS Command+Backspace triggers the so called "Forward Delete".
# It should be treated the same as the Delete button.
is_forward_delete = IS_MACOS and \
event.key() == QtCore.Qt.Key.Key_Backspace and \
event.modifiers() & QtCore.Qt.KeyboardModifier.ControlModifier
if event.matches(QtGui.QKeySequence.StandardKey.Delete) or is_forward_delete:
if self.metadata_box.hasFocus():
self.metadata_box.remove_selected_tags()
else:
self.remove()
elif event.matches(QtGui.QKeySequence.StandardKey.Find):
self.search_edit.setFocus(True)
else:
super().keyPressEvent(event)
def show(self):
self.restoreWindowState()
super().show()
if self.tagger.autoupdate_enabled:
self.auto_update_check()
self.metadata_box.restore_state()
def showEvent(self, event):
if not self.__shown:
self.ready_for_display.emit()
self.__shown = True
super().showEvent(event)
def closeEvent(self, event):
config = get_config()
if config.setting["quit_confirmation"] and not self.show_quit_confirmation():
event.ignore()
return
if self.player:
config.persist['mediaplayer_playback_rate'] = self.player.playback_rate()
config.persist['mediaplayer_volume'] = self.player.volume()
self.saveWindowState()
# Confirm loss of unsaved changes in script editor.
if self.script_editor_dialog:
if not self.script_editor_dialog.unsaved_changes_confirmation():
event.ignore()
return
else:
# Silently close the script editor without displaying the confirmation a second time.
self.script_editor_dialog.loading = True
event.accept()
def _setup_desktop_status_indicator(self):
if DesktopStatusIndicator:
self.register_status_indicator(DesktopStatusIndicator(self.windowHandle()))
def register_status_indicator(self, indicator):
self.status_indicators.append(indicator)
def show_quit_confirmation(self):
unsaved_files = sum(a.get_num_unsaved_files() for a in self.tagger.albums.values())
QMessageBox = QtWidgets.QMessageBox
if unsaved_files > 0:
msg = QMessageBox(self)
msg.setIcon(QMessageBox.Icon.Question)
msg.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
msg.setWindowTitle(_("Unsaved Changes"))
msg.setText(_("Are you sure you want to quit Picard?"))
txt = ngettext(
"There is %d unsaved file. Closing Picard will lose all unsaved changes.",
"There are %d unsaved files. Closing Picard will lose all unsaved changes.",
unsaved_files) % unsaved_files
msg.setInformativeText(txt)
cancel = msg.addButton(QMessageBox.StandardButton.Cancel)
msg.setDefaultButton(cancel)
msg.addButton(_("&Quit Picard"), QMessageBox.ButtonRole.YesRole)
ret = msg.exec_()
if ret == QMessageBox.StandardButton.Cancel:
return False
return True
def saveWindowState(self):
config = get_config()
config.persist["window_state"] = self.saveState()
isMaximized = int(self.windowState()) & QtCore.Qt.WindowState.WindowMaximized != 0
self.save_geometry()
config.persist["window_maximized"] = isMaximized
config.persist["view_metadata_view"] = self.show_metadata_view_action.isChecked()
config.persist["view_cover_art"] = self.show_cover_art_action.isChecked()
config.persist["view_toolbar"] = self.show_toolbar_action.isChecked()
config.persist["view_file_browser"] = self.show_file_browser_action.isChecked()
self.file_browser.save_state()
self.panel.save_state()
self.metadata_box.save_state()
@restore_method
def restoreWindowState(self):
config = get_config()
self.restoreState(config.persist["window_state"])
self.restore_geometry()
if config.persist["window_maximized"]:
self.setWindowState(QtCore.Qt.WindowState.WindowMaximized)
splitters = config.persist["splitters_MainWindow"]
if splitters is None or 'main_window_bottom_splitter' not in splitters:
self.centralWidget().setSizes([366, 194])
self.file_browser.restore_state()
def create_statusbar(self):
"""Creates a new status bar."""
self.statusBar().showMessage(_("Ready"))
infostatus = InfoStatus(self)
self._progress = infostatus.get_progress
self.listening_label = QtWidgets.QLabel()
self.listening_label.setVisible(False)
self.listening_label.setToolTip("<qt/>" + _(
"Picard listens on this port to integrate with your browser. When "
"you \"Search\" or \"Open in Browser\" from Picard, clicking the "
"\"Tagger\" button on the web page loads the release into Picard."
))
self.statusBar().addPermanentWidget(infostatus)
self.statusBar().addPermanentWidget(self.listening_label)
self.tagger.tagger_stats_changed.connect(self.update_statusbar_stats)
self.tagger.listen_port_changed.connect(self.update_statusbar_listen_port)
self.register_status_indicator(infostatus)
self.update_statusbar_stats()
@throttle(100)
def update_statusbar_stats(self):
"""Updates the status bar information."""
total_files = len(self.tagger.files)
total_albums = len(self.tagger.albums)
pending_files = File.num_pending_files
pending_requests = self.tagger.webservice.num_pending_web_requests
for indicator in self.status_indicators:
indicator.update(files=total_files, albums=total_albums,
pending_files=pending_files, pending_requests=pending_requests, progress=self._progress())
def update_statusbar_listen_port(self, listen_port):
if listen_port:
self.listening_label.setVisible(True)
self.listening_label.setText(_(" Listening on port %(port)d ") % {"port": listen_port})
else:
self.listening_label.setVisible(False)
def set_statusbar_message(self, message, *args, **kwargs):
"""Set the status bar message.
*args are passed to % operator, if args[0] is a mapping it is used for
named place holders values
>>> w.set_statusbar_message("File %(filename)s", {'filename': 'x.txt'})
Keyword arguments:
`echo` parameter defaults to `log.debug`, called before message is
translated, it can be disabled passing None or replaced by ie.
`log.error`. If None, skipped.
`translate` is a method called on message before it is sent to history
log and status bar, it defaults to `_()`. If None, skipped.
`timeout` defines duration of the display in milliseconds
`history` is a method called with translated message as argument, it
defaults to `log.history_info`. If None, skipped.
Empty messages are never passed to echo and history functions but they
are sent to status bar (ie. to clear it).
"""
def isdict(obj):
return hasattr(obj, 'keys') and hasattr(obj, '__getitem__')
echo = kwargs.get('echo', log.debug)
# _ is defined using builtins.__dict__, so setting it as default named argument
# value doesn't work as expected
translate = kwargs.get('translate', _)
timeout = kwargs.get('timeout', 0)
history = kwargs.get('history', log.history_info)
if len(args) == 1 and isdict(args[0]):
# named place holders
mparms = args[0]
else:
# simple place holders, ensure compatibility
mparms = args
if message:
if echo:
echo(message % mparms)
if translate:
message = translate(message)
message = message % mparms
if history:
history(message)
thread.to_main(self.statusBar().showMessage, message, timeout)
def _on_submit_acoustid(self):
if self.tagger.use_acoustid:
config = get_config()
if not config.setting["acoustid_apikey"]:
msg = QtWidgets.QMessageBox(self)
msg.setIcon(QtWidgets.QMessageBox.Icon.Information)
msg.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
msg.setWindowTitle(_("AcoustID submission not configured"))
msg.setText(_(
"You need to configure your AcoustID API key before you can submit fingerprints."))
open_options = QtWidgets.QPushButton(
icontheme.lookup('preferences-desktop'), _("Open AcoustID options"))
msg.addButton(QtWidgets.QMessageBox.StandardButton.Cancel)
msg.addButton(open_options, QtWidgets.QMessageBox.ButtonRole.YesRole)
msg.exec_()
if msg.clickedButton() == open_options:
self.show_options("fingerprinting")
else:
self.tagger.acoustidmanager.submit()
@MainWindowActions.add()
def _create_options_action(self):
action = QtWidgets.QAction(icontheme.lookup('preferences-desktop'), _("&Options..."), self)
action.setMenuRole(QtWidgets.QAction.MenuRole.PreferencesRole)
action.triggered.connect(self.show_options)
self.options_action = action
@MainWindowActions.add()
def _create_show_script_editor_action(self):
action = QtWidgets.QAction(_("Open &file naming script editor..."))
action.setShortcut(QtGui.QKeySequence(_("Ctrl+Shift+S")))
action.triggered.connect(self.open_file_naming_script_editor)
self.show_script_editor_action = action
@MainWindowActions.add()
def _create_cut_action(self):
action = QtWidgets.QAction(icontheme.lookup('edit-cut', icontheme.ICON_SIZE_MENU), _("&Cut"), self)
action.setShortcut(QtGui.QKeySequence.StandardKey.Cut)
action.setEnabled(False)
action.triggered.connect(self.cut)
self.cut_action = action
@MainWindowActions.add()
def _create_paste_action(self):
action = QtWidgets.QAction(icontheme.lookup('edit-paste', icontheme.ICON_SIZE_MENU), _("&Paste"), self)
action.setShortcut(QtGui.QKeySequence.StandardKey.Paste)
action.setEnabled(False)
action.triggered.connect(self.paste)
self.paste_action = action
@MainWindowActions.add()
def _create_help_action(self):
action = QtWidgets.QAction(_("&Help..."), self)
action.setShortcut(QtGui.QKeySequence.StandardKey.HelpContents)
action.triggered.connect(self.show_help)
self.help_action = action
@MainWindowActions.add()
def _create_about_action(self):
action = QtWidgets.QAction(_("&About..."), self)
action.setMenuRole(QtWidgets.QAction.MenuRole.AboutRole)
action.triggered.connect(self.show_about)
self.about_action = action
@MainWindowActions.add()
def _create_donate_action(self):
action = QtWidgets.QAction(_("&Donate..."), self)
action.triggered.connect(self.open_donation_page)
self.donate_action = action
@MainWindowActions.add()
def _create_report_bug_action(self):
action = QtWidgets.QAction(_("&Report a Bug..."), self)
action.triggered.connect(self.open_bug_report)
self.report_bug_action = action
@MainWindowActions.add()
def _create_support_forum_action(self):
action = QtWidgets.QAction(_("&Support Forum..."), self)
action.triggered.connect(self.open_support_forum)
self.support_forum_action = action
@MainWindowActions.add()
def _create_add_files_action(self):
action = QtWidgets.QAction(icontheme.lookup('document-open'), _("&Add Files..."), self)
action.setStatusTip(_("Add files to the tagger"))
# TR: Keyboard shortcut for "Add Files..."
action.setShortcut(QtGui.QKeySequence.StandardKey.Open)
action.triggered.connect(self.add_files)
self.add_files_action = action
@MainWindowActions.add()
def _create_add_directory_action(self):
action = QtWidgets.QAction(icontheme.lookup('folder'), _("Add Fold&er..."), self)
action.setStatusTip(_("Add a folder to the tagger"))
# TR: Keyboard shortcut for "Add Directory..."
action.setShortcut(QtGui.QKeySequence(_("Ctrl+E")))
action.triggered.connect(self.add_directory)
self.add_directory_action = action
@MainWindowActions.add()
def _create_close_window_action(self):
if self.show_close_window:
action = QtWidgets.QAction(_("Close Window"), self)
action.setShortcut(QtGui.QKeySequence(_("Ctrl+W")))
action.triggered.connect(self.close_active_window)
else:
action = None
self.close_window_action = action
@MainWindowActions.add()
def _create_save_action(self):
action = QtWidgets.QAction(icontheme.lookup('document-save'), _("&Save"), self)
action.setStatusTip(_("Save selected files"))
# TR: Keyboard shortcut for "Save"
action.setShortcut(QtGui.QKeySequence.StandardKey.Save)
action.setEnabled(False)
action.triggered.connect(self.save)
self.save_action = action
@MainWindowActions.add()
def _create_submit_acoustid_action(self):
action = QtWidgets.QAction(icontheme.lookup('acoustid-fingerprinter'), _("S&ubmit AcoustIDs"), self)
action.setStatusTip(_("Submit acoustic fingerprints"))
action.setEnabled(False)
action.triggered.connect(self._on_submit_acoustid)
self.submit_acoustid_action = action
@MainWindowActions.add()
def _create_exit_action(self):
action = QtWidgets.QAction(_("E&xit"), self)
action.setMenuRole(QtWidgets.QAction.MenuRole.QuitRole)
# TR: Keyboard shortcut for "Exit"
action.setShortcut(QtGui.QKeySequence(_("Ctrl+Q")))
action.triggered.connect(self.close)
self.exit_action = action
@MainWindowActions.add()
def _create_remove_action(self):
action = QtWidgets.QAction(icontheme.lookup('list-remove'), _("&Remove"), self)
action.setStatusTip(_("Remove selected files/albums"))
action.setEnabled(False)
action.triggered.connect(self.remove)
self.remove_action = action
@MainWindowActions.add()
def _create_browser_lookup_action(self):
action = QtWidgets.QAction(icontheme.lookup('lookup-musicbrainz'), _("Lookup in &Browser"), self)
action.setStatusTip(_("Lookup selected item on MusicBrainz website"))
action.setEnabled(False)
# TR: Keyboard shortcut for "Lookup in Browser"
action.setShortcut(QtGui.QKeySequence(_("Ctrl+Shift+L")))
action.triggered.connect(self.browser_lookup)
self.browser_lookup_action = action
@MainWindowActions.add()
def _create_submit_cluster_action(self):
if addrelease.is_available():
action = QtWidgets.QAction(_("Submit cluster as release..."), self)
action.setStatusTip(_("Submit cluster as a new release to MusicBrainz"))
action.setEnabled(False)
action.triggered.connect(self.submit_cluster)
else:
action = None
self.submit_cluster_action = action
@MainWindowActions.add()
def _create_submit_file_as_recording_action(self):
if addrelease.is_available():
action = QtWidgets.QAction(_("Submit file as standalone recording..."), self)
action.setStatusTip(_("Submit file as a new recording to MusicBrainz"))
action.setEnabled(False)
action.triggered.connect(self.submit_file)
else:
action = None
self.submit_file_as_recording_action = action
@MainWindowActions.add()
def _create_submit_file_as_release_action(self):
if addrelease.is_available():
action = QtWidgets.QAction(_("Submit file as release..."), self)
action.setStatusTip(_("Submit file as a new release to MusicBrainz"))
action.setEnabled(False)
action.triggered.connect(partial(self.submit_file, as_release=True))
else:
action = None
self.submit_file_as_release_action = action
@MainWindowActions.add()
def _create_album_search_action(self):
action = QtWidgets.QAction(icontheme.lookup('system-search'), _("Search for similar albums..."), self)
action.setStatusTip(_("View similar releases and optionally choose a different release"))
action.triggered.connect(self.show_more_albums)
self.album_search_action = action
@MainWindowActions.add()
def _create_track_search_action(self):
action = QtWidgets.QAction(icontheme.lookup('system-search'), _("Search for similar tracks..."), self)
action.setStatusTip(_("View similar tracks and optionally choose a different release"))
action.setEnabled(False)
action.setShortcut(QtGui.QKeySequence(_("Ctrl+T")))
action.triggered.connect(self.show_more_tracks)
self.track_search_action = action
@MainWindowActions.add()
def _create_show_file_browser_action(self):
config = get_config()
action = QtWidgets.QAction(_("File &Browser"), self)
action.setCheckable(True)
if config.persist["view_file_browser"]:
action.setChecked(True)
action.setShortcut(QtGui.QKeySequence(_("Ctrl+B")))
action.triggered.connect(self.show_file_browser)
self.show_file_browser_action = action
@MainWindowActions.add()
def _create_show_metadata_view_action(self):
config = get_config()
action = QtWidgets.QAction(_("&Metadata"), self)
action.setCheckable(True)
if config.persist["view_metadata_view"]:
action.setChecked(True)
action.setShortcut(QtGui.QKeySequence(_("Ctrl+Shift+M")))
action.triggered.connect(self.show_metadata_view)
self.show_metadata_view_action = action
@MainWindowActions.add()
def _create_show_cover_art_action(self):
config = get_config()
action = QtWidgets.QAction(_("&Cover Art"), self)
action.setCheckable(True)
if config.persist["view_cover_art"]:
action.setChecked(True)
action.setEnabled(self.show_metadata_view_action.isChecked())
action.triggered.connect(self.show_cover_art)
self.show_cover_art_action = action
@MainWindowActions.add()
def _create_show_toolbar_action(self):
config = get_config()
action = QtWidgets.QAction(_("&Actions"), self)
action.setCheckable(True)
if config.persist["view_toolbar"]:
action.setChecked(True)
action.triggered.connect(self.show_toolbar)
self.show_toolbar_action = action
@MainWindowActions.add()
def _create_search_action(self):
action = QtWidgets.QAction(icontheme.lookup('system-search'), _("Search"), self)
action.setEnabled(False)
action.triggered.connect(self.search)
self.search_action = action
@MainWindowActions.add()
def _create_cd_lookup_action(self):
action = QtWidgets.QAction(icontheme.lookup('media-optical'), _("Lookup &CD..."), self)
action.setStatusTip(_("Lookup the details of the CD in your drive"))
# TR: Keyboard shortcut for "Lookup CD"
action.setShortcut(QtGui.QKeySequence(_("Ctrl+K")))
action.triggered.connect(self.tagger.lookup_cd)
action.setEnabled(False)
self.cd_lookup_action = action
self.cd_lookup_menu = QtWidgets.QMenu(_("Lookup &CD..."))
self.cd_lookup_menu.triggered.connect(self.tagger.lookup_cd)
if discid is None:
log.warning("CDROM: discid library not found - Lookup CD functionality disabled")
else:
thread.run_task(get_cdrom_drives, self._update_cd_lookup_actions)
@MainWindowActions.add()
def _create_analyze_action(self):
action = QtWidgets.QAction(icontheme.lookup('picard-analyze'), _("&Scan"), self)
action.setStatusTip(_("Use AcoustID audio fingerprint to identify the files by the actual music, even if they have no metadata"))
action.setEnabled(False)
action.setToolTip(_('Identify the file using its AcoustID audio fingerprint'))
# TR: Keyboard shortcut for "Analyze"
action.setShortcut(QtGui.QKeySequence(_("Ctrl+Y")))
action.triggered.connect(self.analyze)
self.analyze_action = action
@MainWindowActions.add()
def _create_generate_fingerprints_action(self):
action = QtWidgets.QAction(icontheme.lookup('fingerprint'), _("&Generate AcoustID Fingerprints"), self)
action.setIconText(_("Generate Fingerprints"))
action.setStatusTip(_("Generate the AcoustID audio fingerprints for the selected files without doing a lookup"))
action.setEnabled(False)
action.setToolTip(_('Generate the AcoustID audio fingerprints for the selected files'))
action.setShortcut(QtGui.QKeySequence(_("Ctrl+Shift+Y")))
action.triggered.connect(self.generate_fingerprints)
self.generate_fingerprints_action = action
@MainWindowActions.add()
def _create_cluster_action(self):
action = QtWidgets.QAction(icontheme.lookup('picard-cluster'), _("Cl&uster"), self)
action.setStatusTip(_("Cluster files into album clusters"))
action.setEnabled(False)
# TR: Keyboard shortcut for "Cluster"
action.setShortcut(QtGui.QKeySequence(_("Ctrl+U")))
action.triggered.connect(self.cluster)
self.cluster_action = action
@MainWindowActions.add()
def _create_autotag_action(self):
action = QtWidgets.QAction(icontheme.lookup('picard-auto-tag'), _("&Lookup"), self)
tip = _("Lookup selected items in MusicBrainz")
action.setToolTip(tip)
action.setStatusTip(tip)
action.setEnabled(False)
# TR: Keyboard shortcut for "Lookup"
action.setShortcut(QtGui.QKeySequence(_("Ctrl+L")))
action.triggered.connect(self.autotag)
self.autotag_action = action
@MainWindowActions.add()
def _create_view_info_action(self):
action = QtWidgets.QAction(icontheme.lookup('picard-edit-tags'), _("&Info..."), self)
action.setEnabled(False)
# TR: Keyboard shortcut for "Info"
action.setShortcut(QtGui.QKeySequence(_("Ctrl+I")))
action.triggered.connect(self.view_info)
self.view_info_action = action
@MainWindowActions.add()
def _create_refresh_action(self):
action = QtWidgets.QAction(icontheme.lookup('view-refresh', icontheme.ICON_SIZE_MENU), _("&Refresh"), self)
action.setShortcut(QtGui.QKeySequence(_("Ctrl+R")))
action.triggered.connect(self.refresh)
self.refresh_action = action
@MainWindowActions.add()
def _create_enable_renaming_action(self):
config = get_config()
action = QtWidgets.QAction(_("&Rename Files"), self)
action.setCheckable(True)
action.setChecked(config.setting["rename_files"])
action.triggered.connect(self.toggle_rename_files)
self.enable_renaming_action = action
@MainWindowActions.add()
def _create_enable_moving_action(self):
config = get_config()
action = QtWidgets.QAction(_("&Move Files"), self)
action.setCheckable(True)
action.setChecked(config.setting["move_files"])
action.triggered.connect(self.toggle_move_files)
self.enable_moving_action = action
@MainWindowActions.add()
def _create_enable_tag_saving_action(self):
config = get_config()
action = QtWidgets.QAction(_("Save &Tags"), self)
action.setCheckable(True)
action.setChecked(not config.setting["dont_write_tags"])
action.triggered.connect(self.toggle_tag_saving)
self.enable_tag_saving_action = action
@MainWindowActions.add()
def _create_tags_from_filenames_action(self):
action = QtWidgets.QAction(icontheme.lookup('picard-tags-from-filename'), _("Tags From &File Names..."), self)
action.setIconText(_("Parse File Names..."))
action.setToolTip(_('Set tags based on the file names'))
action.setShortcut(QtGui.QKeySequence(_("Ctrl+Shift+T")))
action.setEnabled(False)
action.triggered.connect(self.open_tags_from_filenames)
self.tags_from_filenames_action = action
@MainWindowActions.add()
def _create_open_collection_in_browser_action(self):
config = get_config()
action = QtWidgets.QAction(_("&Open My Collections in Browser"), self)
action.setEnabled(config.setting["username"] != '')
action.triggered.connect(self.open_collection_in_browser)
self.open_collection_in_browser_action = action
@MainWindowActions.add()
def _create_view_log_action(self):
action = QtWidgets.QAction(_("View &Error/Debug Log"), self)
# TR: Keyboard shortcut for "View Error/Debug Log"
action.setShortcut(QtGui.QKeySequence(_("Ctrl+G")))
action.triggered.connect(self.show_log)
self.view_log_action = action
@MainWindowActions.add()
def _create_view_history_action(self):
action = QtWidgets.QAction(_("View Activity &History"), self)
# TR: Keyboard shortcut for "View Activity History"
# On macOS ⌘+H is a system shortcut to hide the window. Use ⌘+Shift+H instead.
action.setShortcut(QtGui.QKeySequence(_("Ctrl+Shift+H") if IS_MACOS else _("Ctrl+H")))
action.triggered.connect(self.show_history)
self.view_history_action = action
@MainWindowActions.add()
def _create_play_file_action(self):
action = QtWidgets.QAction(icontheme.lookup('play-music'), _("Open in &Player"), self)
action.setStatusTip(_("Play the file in your default media player"))
action.setEnabled(False)
action.triggered.connect(self.play_file)
self.play_file_action = action
@MainWindowActions.add()
def _create_open_folder_action(self):
action = QtWidgets.QAction(icontheme.lookup('folder', icontheme.ICON_SIZE_MENU), _("Open Containing &Folder"), self)
action.setStatusTip(_("Open the containing folder in your file explorer"))
action.setEnabled(False)
action.triggered.connect(self.open_folder)
self.open_folder_action = action
@MainWindowActions.add()
def _create_check_update_action(self):
if self.tagger.autoupdate_enabled:
action = QtWidgets.QAction(_("&Check for Update…"), self)
action.setMenuRole(QtWidgets.QAction.MenuRole.ApplicationSpecificRole)
action.triggered.connect(self.do_update_check)
else:
action = None
self.check_update_action = action
def create_actions(self):
MainWindowActions.create(self)
webservice_manager = self.tagger.webservice.manager
webservice_manager.authenticationRequired.connect(self.show_password_dialog)
webservice_manager.proxyAuthenticationRequired.connect(self.show_proxy_dialog)
def _update_cd_lookup_actions(self, result=None, error=None):
if error:
log.error("CDROM: Error on CD-ROM drive detection: %r", error)
else:
self.update_cd_lookup_drives(result)
def update_cd_lookup_drives(self, drives):
if not drives:
log.warning("CDROM: No CD-ROM drives found - Lookup CD functionality disabled")
else:
config = get_config()
shortcut_drive = config.setting["cd_lookup_device"].split(",")[0] if len(drives) > 1 else ""
self.cd_lookup_action.setEnabled(discid is not None)
self.cd_lookup_menu.clear()
for drive in drives:
action = self.cd_lookup_menu.addAction(drive)
action.setData(drive)
if drive == shortcut_drive:
# Clear existing shortcode on main action and assign it to sub-action
self.cd_lookup_action.setShortcut(QtGui.QKeySequence())
action.setShortcut(QtGui.QKeySequence(_("Ctrl+K")))
self._set_cd_lookup_from_file_actions()
self._update_cd_lookup_button()
def _set_cd_lookup_from_file_actions(self):
if len(self.cd_lookup_menu.actions()) > 0:
self.cd_lookup_menu.addSeparator()
action = self.cd_lookup_menu.addAction(_('From EAC / XLD / Whipper &log file...'))
action.setData('logfile:eac')
def _update_cd_lookup_button(self):
if len(self.cd_lookup_menu.actions()) > 1:
button = self.toolbar.widgetForAction(self.cd_lookup_action)
if button:
button.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.MenuButtonPopup)
self.cd_lookup_action.setMenu(self.cd_lookup_menu)
else:
self.cd_lookup_action.setMenu(None)
def toggle_rename_files(self, checked):
config = get_config()
config.setting["rename_files"] = checked
self.update_script_editor_examples()
def toggle_move_files(self, checked):
config = get_config()
config.setting["move_files"] = checked
self.update_script_editor_examples()
def toggle_tag_saving(self, checked):
config = get_config()
config.setting["dont_write_tags"] = not checked
def get_selected_or_unmatched_files(self):
if self.selected_objects:
files = list(iter_files_from_objects(self.selected_objects))
if files:
return files
return self.tagger.unclustered_files.files
def open_tags_from_filenames(self):
files = self.get_selected_or_unmatched_files()
if files:
dialog = TagsFromFileNamesDialog(files, self)
dialog.exec_()
def open_collection_in_browser(self):
self.tagger.collection_lookup()
def create_menus(self):
menu = self.menuBar().addMenu(_("&File"))
menu.addAction(self.add_directory_action)
menu.addAction(self.add_files_action)
if self.show_close_window:
menu.addAction(self.close_window_action)
menu.addSeparator()
menu.addAction(self.play_file_action)
menu.addAction(self.open_folder_action)
menu.addSeparator()
menu.addAction(self.save_action)
menu.addAction(self.submit_acoustid_action)
menu.addSeparator()
menu.addAction(self.exit_action)
menu = self.menuBar().addMenu(_("&Edit"))
menu.addAction(self.cut_action)
menu.addAction(self.paste_action)
menu.addSeparator()
menu.addAction(self.view_info_action)
menu.addAction(self.remove_action)
menu = self.menuBar().addMenu(_("&View"))
menu.addAction(self.show_file_browser_action)
menu.addAction(self.show_metadata_view_action)
menu.addAction(self.show_cover_art_action)
menu.addSeparator()
menu.addAction(self.show_toolbar_action)
menu.addAction(self.search_toolbar_toggle_action)
if self.player:
menu.addAction(self.player_toolbar_toggle_action)
menu = self.menuBar().addMenu(_("&Options"))
menu.addAction(self.enable_renaming_action)
menu.addAction(self.enable_moving_action)
menu.addAction(self.enable_tag_saving_action)
menu.addSeparator()
self.script_quick_selector_menu = QtWidgets.QMenu(_("&Select file naming script"))
self.script_quick_selector_menu.setIcon(icontheme.lookup('document-open'))
self.make_script_selector_menu()
menu.addMenu(self.script_quick_selector_menu)
menu.addAction(self.show_script_editor_action)
menu.addSeparator()
self.profile_quick_selector_menu = QtWidgets.QMenu(_("&Enable/disable profiles"))
# self.profile_quick_selector_menu.setIcon(icontheme.lookup('document-open'))
self.make_profile_selector_menu()
menu.addMenu(self.profile_quick_selector_menu)
menu.addSeparator()
menu.addAction(self.options_action)
menu = self.menuBar().addMenu(_("&Tools"))
menu.addAction(self.refresh_action)
menu.addAction(self.cd_lookup_action)
menu.addAction(self.autotag_action)
menu.addAction(self.analyze_action)
menu.addAction(self.cluster_action)
menu.addAction(self.browser_lookup_action)
menu.addAction(self.track_search_action)
menu.addSeparator()
menu.addAction(self.generate_fingerprints_action)
menu.addAction(self.tags_from_filenames_action)
menu.addAction(self.open_collection_in_browser_action)
self.menuBar().addSeparator()
menu = self.menuBar().addMenu(_("&Help"))
menu.addAction(self.help_action)
menu.addSeparator()
menu.addAction(self.view_history_action)
menu.addSeparator()
if self.tagger.autoupdate_enabled:
menu.addAction(self.check_update_action)
menu.addSeparator()
menu.addAction(self.support_forum_action)
menu.addAction(self.report_bug_action)
menu.addAction(self.view_log_action)
menu.addSeparator()
menu.addAction(self.donate_action)
menu.addAction(self.about_action)
def update_toolbar_style(self):
config = get_config()
style = QtCore.Qt.ToolButtonStyle.ToolButtonIconOnly
if config.setting["toolbar_show_labels"]:
style = QtCore.Qt.ToolButtonStyle.ToolButtonTextUnderIcon
self.toolbar.setToolButtonStyle(style)
if self.player:
self.player.toolbar.setToolButtonStyle(style)
def create_toolbar(self):
self.create_search_toolbar()
if self.player:
self.create_player_toolbar()
self.create_action_toolbar()
def create_action_toolbar(self):
if self.toolbar:
self.toolbar.clear()
self.removeToolBar(self.toolbar)
self.toolbar = toolbar = QtWidgets.QToolBar(_("Actions"))
self.insertToolBar(self.search_toolbar, self.toolbar)
self.update_toolbar_style()
toolbar.setObjectName("main_toolbar")
if IS_MACOS:
self.toolbar.setMovable(False)
def add_toolbar_action(action):
toolbar.addAction(action)
widget = toolbar.widgetForAction(action)
widget.setFocusPolicy(QtCore.Qt.FocusPolicy.TabFocus)
widget.setAttribute(QtCore.Qt.WidgetAttribute.WA_MacShowFocusRect)
config = get_config()
for action in config.setting['toolbar_layout']:
if action == 'cd_lookup_action':
add_toolbar_action(self.cd_lookup_action)
self._update_cd_lookup_button()
elif action == 'separator':
toolbar.addSeparator()
else:
try:
add_toolbar_action(getattr(self, action))
except AttributeError:
log.warning('Warning: Unknown action name "%r" found in config. Ignored.', action)
self.show_toolbar()
def create_player_toolbar(self):
""""Create a toolbar with internal player control elements"""
toolbar = self.player.create_toolbar()
self.addToolBar(QtCore.Qt.ToolBarArea.BottomToolBarArea, toolbar)
self.player_toolbar_toggle_action = toolbar.toggleViewAction()
toolbar.hide() # Hide by default
def create_search_toolbar(self):
config = get_config()
self.search_toolbar = toolbar = self.addToolBar(_("Search"))
self.search_toolbar_toggle_action = self.search_toolbar.toggleViewAction()
toolbar.setObjectName("search_toolbar")
if IS_MACOS:
self.search_toolbar.setMovable(False)
search_panel = QtWidgets.QWidget(toolbar)
hbox = QtWidgets.QHBoxLayout(search_panel)
self.search_combo = QtWidgets.QComboBox(search_panel)
self.search_combo.addItem(_("Album"), "album")
self.search_combo.addItem(_("Artist"), "artist")
self.search_combo.addItem(_("Track"), "track")
hbox.addWidget(self.search_combo, 0)
self.search_edit = QtWidgets.QLineEdit(search_panel)
self.search_edit.setClearButtonEnabled(True)
self.search_edit.returnPressed.connect(self.trigger_search_action)
self.search_edit.textChanged.connect(self.enable_search)
hbox.addWidget(self.search_edit, 0)
self.search_button = QtWidgets.QToolButton(search_panel)
self.search_button.setAutoRaise(True)
self.search_button.setDefaultAction(self.search_action)
self.search_button.setIconSize(QtCore.QSize(22, 22))
self.search_button.setAttribute(QtCore.Qt.WidgetAttribute.WA_MacShowFocusRect)
# search button contextual menu, shortcut to toggle search options
def search_button_menu(position):
menu = QtWidgets.QMenu()
opts = OrderedDict([
('use_adv_search_syntax', N_("&Advanced search")),
('builtin_search', N_("&Builtin search"))
])
def toggle_opt(opt, checked):
config.setting[opt] = checked
for opt, label in opts.items():
action = QtWidgets.QAction(_(label), menu)
action.setCheckable(True)
action.setChecked(config.setting[opt])
action.triggered.connect(partial(toggle_opt, opt))
menu.addAction(action)
menu.exec_(self.search_button.mapToGlobal(position))
self.search_button.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
self.search_button.customContextMenuRequested.connect(search_button_menu)
hbox.addWidget(self.search_button)
toolbar.addWidget(search_panel)
def set_tab_order(self):
tab_order = self.setTabOrder
tw = self.toolbar.widgetForAction
prev_action = None
current_action = None
# Setting toolbar widget tab-orders for accessibility
config = get_config()
for action in config.setting['toolbar_layout']:
if action != 'separator':
try:
current_action = tw(getattr(self, action))
except AttributeError:
# No need to log warnings since we have already
# done it once in create_toolbar
pass
if prev_action is not None and prev_action != current_action:
tab_order(prev_action, current_action)
prev_action = current_action
tab_order(prev_action, self.search_combo)
tab_order(self.search_combo, self.search_edit)
tab_order(self.search_edit, self.search_button)
# Panels
tab_order(self.search_button, self.file_browser)
self.panel.tab_order(tab_order, self.file_browser, self.metadata_box)
def enable_submit(self, enabled):
"""Enable/disable the 'Submit fingerprints' action."""
self.submit_acoustid_action.setEnabled(enabled)
def enable_cluster(self, enabled):
"""Enable/disable the 'Cluster' action."""
self.cluster_action.setEnabled(enabled)
def enable_search(self):
"""Enable/disable the 'Search' action."""
self.search_action.setEnabled(bool(self.search_edit.text()))
def trigger_search_action(self):
if self.search_action.isEnabled():
self.search_action.trigger()
def search_mbid_found(self, entity, mbid):
self.search_edit.setText('%s:%s' % (entity, mbid))
def search(self):
"""Search for album, artist or track on the MusicBrainz website."""
text = self.search_edit.text()
entity = self.search_combo.itemData(self.search_combo.currentIndex())
config = get_config()
self.tagger.search(text, entity,
config.setting["use_adv_search_syntax"],
mbid_matched_callback=self.search_mbid_found)
def add_files(self):
"""Add files to the tagger."""
current_directory = find_starting_directory()
formats = []
extensions = []
for exts, name in supported_formats():
exts = ["*" + e.lower() for e in exts]
if not exts:
continue
if not IS_MACOS and not IS_WIN:
# Also consider upper case extensions
# macOS and Windows usually support case sensitive file names. Furthermore on both systems
# the file dialog filters list all extensions we provide, which becomes a bit long when we give the
# full list twice. Hence only do this trick on other operating systems.
exts.extend([e.upper() for e in exts])
exts.sort()
formats.append("%s (%s)" % (name, " ".join(exts)))
extensions.extend(exts)
formats.sort()
extensions.sort()
formats.insert(0, _("All supported formats") + " (%s)" % " ".join(extensions))
formats.insert(1, _("All files") + " (*)")
files, _filter = QtWidgets.QFileDialog.getOpenFileNames(self, "", current_directory, ";;".join(formats))
if files:
config = get_config()
config.persist["current_directory"] = os.path.dirname(files[0])
self.tagger.add_files(files)
def add_directory(self):
"""Add directory to the tagger."""
current_directory = find_starting_directory()
dir_list = []
config = get_config()
if not config.setting["toolbar_multiselect"]:
directory = QtWidgets.QFileDialog.getExistingDirectory(self, "", current_directory)
if directory:
dir_list.append(directory)
else:
file_dialog = MultiDirsSelectDialog(self, "", current_directory)
if file_dialog.exec_() == QtWidgets.QDialog.DialogCode.Accepted:
dir_list = file_dialog.selectedFiles()
dir_count = len(dir_list)
if dir_count:
parent = os.path.dirname(dir_list[0]) if dir_count > 1 else dir_list[0]
config.persist["current_directory"] = parent
if dir_count > 1:
self.set_statusbar_message(
N_("Adding multiple directories from '%(directory)s' ..."),
{'directory': parent}
)
else:
self.set_statusbar_message(
N_("Adding directory: '%(directory)s' ..."),
{'directory': dir_list[0]}
)
self.tagger.add_paths(dir_list)
def close_active_window(self):
self.tagger.activeWindow().close()
def show_about(self):
return AboutDialog.show_instance(self)
def show_options(self, page=None):
options_dialog = OptionsDialog.show_instance(page, self)
options_dialog.finished.connect(self.options_closed)
if self.script_editor_dialog is not None:
# Disable signal processing to avoid saving changes not processed with "Make It So!"
for signal in self.script_editor_signals:
signal.disconnect()
return options_dialog
def options_closed(self):
if self.script_editor_dialog is not None:
self.open_file_naming_script_editor()
self.script_editor_dialog.show()
else:
self.show_script_editor_action.setEnabled(True)
self.make_profile_selector_menu()
self.make_script_selector_menu()
def show_help(self):
webbrowser2.open('documentation')
def _show_log_dialog(self, dialog):
dialog.show()
dialog.raise_()
dialog.activateWindow()
def show_log(self):
self._show_log_dialog(self.log_dialog)
def show_history(self):
self._show_log_dialog(self.history_dialog)
def open_bug_report(self):
webbrowser2.open('troubleshooting')
def open_support_forum(self):
webbrowser2.open('forum')
def open_donation_page(self):
webbrowser2.open('donate')
def save(self):
"""Tell the tagger to save the selected objects."""
self.tagger.save(self.selected_objects)
def remove(self):
"""Tell the tagger to remove the selected objects."""
self.panel.remove(self.selected_objects)
def analyze(self):
def callback(fingerprinting_system):
if fingerprinting_system:
self.tagger.analyze(self.selected_objects)
self._ensure_fingerprinting_configured(callback)
def generate_fingerprints(self):
def callback(fingerprinting_system):
if fingerprinting_system:
self.tagger.generate_fingerprints(self.selected_objects)
self._ensure_fingerprinting_configured(callback)
def _openUrl(self, url):
return QtCore.QUrl.fromLocalFile(url)
def play_file(self):
for file in iter_files_from_objects(self.selected_objects):
QtGui.QDesktopServices.openUrl(self._openUrl(file.filename))
def _on_player_error(self, error, msg):
self.set_statusbar_message(msg, echo=log.warning, translate=None)
def open_folder(self):
folders = iter_unique(
os.path.dirname(f.filename) for f
in iter_files_from_objects(self.selected_objects))
for folder in folders:
QtGui.QDesktopServices.openUrl(self._openUrl(folder))
def _ensure_fingerprinting_configured(self, callback):
config = get_config()
if not config.setting['fingerprinting_system']:
if self._show_analyze_settings_info():
def on_finished(result):
callback(config.setting['fingerprinting_system'])
dialog = self.show_options("fingerprinting")
dialog.finished.connect(on_finished)
else:
callback(config.setting['fingerprinting_system'])
def _show_analyze_settings_info(self):
ret = QtWidgets.QMessageBox.question(self,
_("Configuration Required"),
_("Audio fingerprinting is not yet configured. Would you like to configure it now?"),
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
QtWidgets.QMessageBox.StandardButton.Yes)
return ret == QtWidgets.QMessageBox.StandardButton.Yes
def get_first_obj_with_type(self, type):
for obj in self.selected_objects:
if isinstance(obj, type):
return obj
return None
def show_more_tracks(self):
if not self.selected_objects:
return
obj = self.selected_objects[0]
if isinstance(obj, Track) and obj.files:
obj = obj.files[0]
if not isinstance(obj, File):
log.debug('show_more_tracks expected a File, got %r' % obj)
return
dialog = TrackSearchDialog(self)
dialog.load_similar_tracks(obj)
dialog.exec_()
def show_more_albums(self):
obj = self.get_first_obj_with_type(Cluster)
if not obj:
log.debug('show_more_albums expected a Cluster, got %r' % obj)
return
dialog = AlbumSearchDialog(self)
dialog.show_similar_albums(obj)
dialog.exec_()
def view_info(self, default_tab=0):
try:
selected = self.selected_objects[0]
except IndexError:
return
if isinstance(selected, Album):
dialog_class = AlbumInfoDialog
elif isinstance(selected, Cluster):
dialog_class = ClusterInfoDialog
elif isinstance(selected, Track):
dialog_class = TrackInfoDialog
else:
try:
selected = next(iter_files_from_objects(self.selected_objects))
except StopIteration:
return
dialog_class = FileInfoDialog
dialog = dialog_class(selected, self)
dialog.ui.tabWidget.setCurrentIndex(default_tab)
dialog.exec_()
def cluster(self):
self.tagger.cluster(self.selected_objects, self._cluster_finished)
def _cluster_finished(self):
self.panel.update_current_view()
# Select clusters if no other item or only empty unclustered files item is selected
if not self.selected_objects or (len(self.selected_objects) == 1
and self.tagger.unclustered_files in self.selected_objects
and not self.tagger.unclustered_files.files):
self.panel.select_object(self.tagger.clusters)
self.update_actions()
def refresh(self):
self.tagger.refresh(self.selected_objects)
def browser_lookup(self):
if not self.selected_objects:
return
self.tagger.browser_lookup(self.selected_objects[0])
def submit_cluster(self):
if self.selected_objects and self._check_add_release():
for obj in self.selected_objects:
if isinstance(obj, Cluster):
addrelease.submit_cluster(obj)
def submit_file(self, as_release=False):
if self.selected_objects and self._check_add_release():
for file in iter_files_from_objects(self.selected_objects):
addrelease.submit_file(file, as_release=as_release)
def _check_add_release(self):
if addrelease.is_enabled():
return True
ret = QtWidgets.QMessageBox.question(self,
_("Browser integration not enabled"),
_("Submitting releases to MusicBrainz requires the browser integration to be enabled. Do you want to enable the browser integration now?"),
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
QtWidgets.QMessageBox.StandardButton.Yes)
if ret == QtWidgets.QMessageBox.StandardButton.Yes:
config = get_config()
config.setting["browser_integration"] = True
self.tagger.update_browser_integration()
if addrelease.is_enabled():
return True
else:
# Something went wrong, let the user configure browser integration manually
self.show_options("network")
return False
else:
return False
@throttle(100)
def update_actions(self):
can_remove = False
can_save = False
can_analyze = False
can_refresh = False
can_autotag = False
can_submit = False
single = self.selected_objects[0] if len(self.selected_objects) == 1 else None
can_view_info = bool(single and single.can_view_info())
can_browser_lookup = bool(single and single.can_browser_lookup())
is_file = bool(single and isinstance(single, (File, Track)))
if not self.selected_objects:
have_objects = have_files = False
else:
have_objects = True
try:
next(iter_files_from_objects(self.selected_objects))
have_files = True
except StopIteration:
have_files = False
for obj in self.selected_objects:
if obj is None:
continue
# using x = x or obj.x() form prevents calling function
# if x is already True
can_analyze = can_analyze or obj.can_analyze()
can_autotag = can_autotag or obj.can_autotag()
can_refresh = can_refresh or obj.can_refresh()
can_remove = can_remove or obj.can_remove()
can_save = can_save or obj.can_save()
can_submit = can_submit or obj.can_submit()
# Skip further loops if all values now True.
if (
can_analyze
and can_autotag
and can_refresh
and can_remove
and can_save
and can_submit
):
break
self.remove_action.setEnabled(can_remove)
self.save_action.setEnabled(can_save)
self.view_info_action.setEnabled(can_view_info)
self.analyze_action.setEnabled(can_analyze)
self.generate_fingerprints_action.setEnabled(have_files)
self.refresh_action.setEnabled(can_refresh)
self.autotag_action.setEnabled(can_autotag)
self.browser_lookup_action.setEnabled(can_browser_lookup)
self.play_file_action.setEnabled(have_files)
self.open_folder_action.setEnabled(have_files)
self.cut_action.setEnabled(have_objects)
if self.submit_cluster_action:
self.submit_cluster_action.setEnabled(can_submit)
if self.submit_file_as_recording_action:
self.submit_file_as_recording_action.setEnabled(have_files)
if self.submit_file_as_release_action:
self.submit_file_as_release_action.setEnabled(have_files)
files = self.get_selected_or_unmatched_files()
self.tags_from_filenames_action.setEnabled(bool(files))
self.track_search_action.setEnabled(is_file)
def update_selection(self, objects=None, new_selection=True, drop_album_caches=False):
if self.ignore_selection_changes:
return
if objects is not None:
self.selected_objects = objects
else:
objects = self.selected_objects
self.update_actions()
obj = None
# Clear any existing status bar messages
self.set_statusbar_message("")
if self.player:
self.player.set_objects(self.selected_objects)
metadata_visible = self.metadata_view.isVisible()
coverart_visible = metadata_visible and self.cover_art_box.isVisible()
if len(objects) == 1:
obj = list(objects)[0]
if isinstance(obj, File):
if obj.state == obj.ERROR:
msg = N_("%(filename)s (error: %(error)s)")
mparms = {
'filename': obj.filename,
'error': obj.errors[0] if obj.errors else ''
}
else:
msg = N_("%(filename)s")
mparms = {
'filename': obj.filename,
}
self.set_statusbar_message(msg, mparms, echo=None, history=None)
elif isinstance(obj, Track):
if obj.num_linked_files == 1:
file = obj.files[0]
if file.has_error():
msg = N_("%(filename)s (%(similarity)d%%) (error: %(error)s)")
mparms = {
'filename': file.filename,
'similarity': file.similarity * 100,
'error': file.errors[0] if file.errors else ''
}
else:
msg = N_("%(filename)s (%(similarity)d%%)")
mparms = {
'filename': file.filename,
'similarity': file.similarity * 100,
}
self.set_statusbar_message(msg, mparms, echo=None,
history=None)
elif coverart_visible and new_selection:
# Create a temporary file list which allows changing cover art for all selected files
files = list(iter_files_from_objects(objects))
obj = FileList(files)
if coverart_visible and new_selection:
self.cover_art_box.set_item(obj)
if metadata_visible:
if new_selection:
self.metadata_box.selection_dirty = True
self.metadata_box.update(drop_album_caches=drop_album_caches)
self.selection_updated.emit(objects)
self.update_script_editor_example_files()
def refresh_metadatabox(self):
self.tagger.window.metadata_box.selection_dirty = True
self.tagger.window.metadata_box.update()
def show_metadata_view(self):
"""Show/hide the metadata view (including the cover art box)."""
show = self.show_metadata_view_action.isChecked()
self.metadata_view.setVisible(show)
self.show_cover_art_action.setEnabled(show)
if show:
self.update_selection()
def show_cover_art(self):
"""Show/hide the cover art box."""
show = self.show_cover_art_action.isChecked()
self.cover_art_box.setVisible(show)
if show:
self.update_selection()
def show_toolbar(self):
"""Show/hide the Action toolbar."""
if self.show_toolbar_action.isChecked():
self.toolbar.show()
else:
self.toolbar.hide()
def show_file_browser(self):
"""Show/hide the file browser."""
if self.show_file_browser_action.isChecked():
sizes = self.panel.sizes()
if sizes[0] == 0:
sizes[0] = sum(sizes) // 4
self.panel.setSizes(sizes)
self.file_browser.show()
else:
self.file_browser.hide()
def show_password_dialog(self, reply, authenticator):
config = get_config()
if reply.url().host() == config.setting['server_host']:
ret = QtWidgets.QMessageBox.question(self,
_("Authentication Required"),
_("Picard needs authorization to access your personal data on the MusicBrainz server. Would you like to log in now?"),
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
QtWidgets.QMessageBox.StandardButton.Yes)
if ret == QtWidgets.QMessageBox.StandardButton.Yes:
self.tagger.mb_login(self.on_mb_login_finished)
else:
dialog = PasswordDialog(authenticator, reply, parent=self)
dialog.exec_()
def on_mb_login_finished(self, successful, error_msg):
if successful:
log.debug('MusicBrainz authentication finished successfully')
else:
log.info('MusicBrainz authentication failed: %s', error_msg)
QtWidgets.QMessageBox.critical(self,
_("Authentication failed"),
_('Login failed: %s') % error_msg)
def show_proxy_dialog(self, proxy, authenticator):
dialog = ProxyDialog(authenticator, proxy, parent=self)
dialog.exec_()
def autotag(self):
self.tagger.autotag(self.selected_objects)
def copy_files(self, objects):
mimeData = QtCore.QMimeData()
mimeData.setUrls(QtCore.QUrl.fromLocalFile(f.filename) for f in iter_files_from_objects(objects))
self.tagger.clipboard().setMimeData(mimeData)
def paste_files(self, target):
mimeData = self.tagger.clipboard().mimeData()
if mimeData.hasUrls():
BaseTreeView.drop_urls(mimeData.urls(), target)
def cut(self):
self.copy_files(self.selected_objects)
self.paste_action.setEnabled(bool(self.selected_objects))
def paste(self):
selected_objects = self.selected_objects
if not selected_objects:
target = self.tagger.unclustered_files
else:
target = selected_objects[0]
self.paste_files(target)
self.paste_action.setEnabled(False)
def do_update_check(self):
self.check_for_update(True)
def auto_update_check(self):
config = get_config()
check_for_updates = config.setting['check_for_updates']
update_check_days = config.setting['update_check_days']
last_update_check = config.persist['last_update_check']
update_level = config.setting['update_level']
today = datetime.date.today().toordinal()
do_auto_update_check = check_for_updates and update_check_days > 0 and today >= last_update_check + update_check_days
log.debug('{check_status} start-up check for program updates. Today: {today_date}, Last check: {last_check} (Check interval: {check_interval} days), Update level: {update_level} ({update_level_name})'.format(
check_status='Initiating' if do_auto_update_check else 'Skipping',
today_date=datetime.date.today(),
last_check=str(datetime.date.fromordinal(last_update_check)) if last_update_check > 0 else 'never',
check_interval=update_check_days,
update_level=update_level,
update_level_name=PROGRAM_UPDATE_LEVELS[update_level]['name'] if update_level in PROGRAM_UPDATE_LEVELS else 'unknown',
))
if do_auto_update_check:
self.check_for_update(False)
def check_for_update(self, show_always):
config = get_config()
self.tagger.updatecheckmanager.check_update(
show_always=show_always,
update_level=config.setting['update_level'],
callback=update_last_check_date
)
def check_and_repair_profiles(self):
"""Check the profiles and profile settings and repair the values if required.
Checks that there is a settings dictionary for each profile, and that no profiles
reference a non-existant file naming script.
"""
script_id_key = "selected_file_naming_script_id"
config = get_config()
naming_scripts = config.setting["file_renaming_scripts"]
naming_script_ids = set(naming_scripts.keys())
naming_script_ids |= set(item["id"] for item in get_file_naming_script_presets())
profile_settings = deepcopy(config.profiles[SettingConfigSection.SETTINGS_KEY])
for profile in config.profiles[SettingConfigSection.PROFILES_KEY]:
p_id = profile["id"]
# Add empty settings if none found for a profile
if p_id not in profile_settings:
log.warning(
"No settings dict found for profile '%s' (\"%s\"). Adding empty dict.",
p_id,
profile["title"],
)
profile_settings[p_id] = {}
# Remove any invalid naming script ids from profiles
if script_id_key in profile_settings[p_id]:
if profile_settings[p_id][script_id_key] not in naming_script_ids:
log.warning(
"Removing invalid naming script id '%s' from profile '%s' (\"%s\")",
profile_settings[p_id][script_id_key],
p_id,
profile["title"],
)
profile_settings[p_id][script_id_key] = None
config.profiles[SettingConfigSection.SETTINGS_KEY] = profile_settings
def make_script_selector_menu(self):
"""Update the sub-menu of available file naming scripts.
"""
if self.script_editor_dialog is None or not isinstance(self.script_editor_dialog, ScriptEditorDialog):
config = get_config()
naming_scripts = config.setting["file_renaming_scripts"]
selected_script_id = config.setting["selected_file_naming_script_id"]
else:
naming_scripts = self.script_editor_dialog.naming_scripts
selected_script_id = self.script_editor_dialog.selected_script_id
self.script_quick_selector_menu.clear()
group = QtWidgets.QActionGroup(self.script_quick_selector_menu)
group.setExclusive(True)
def _add_menu_item(title, id):
script_action = QtWidgets.QAction(title, self.script_quick_selector_menu)
script_action.triggered.connect(partial(self.select_new_naming_script, id))
script_action.setCheckable(True)
script_action.setChecked(id == selected_script_id)
self.script_quick_selector_menu.addAction(script_action)
group.addAction(script_action)
for (id, naming_script) in sorted(naming_scripts.items(), key=lambda item: item[1]['title']):
_add_menu_item(user_script_title(naming_script['title']), id)
# Add preset scripts not provided in the user-defined scripts list.
for script_item in get_file_naming_script_presets():
_add_menu_item(script_item['title'], script_item['id'])
def select_new_naming_script(self, id):
"""Update the currently selected naming script ID in the settings.
Args:
id (str): ID of the selected file naming script
"""
config = get_config()
log.debug("Setting naming script to: %s", id)
config.setting["selected_file_naming_script_id"] = id
self.make_script_selector_menu()
if self.script_editor_dialog:
self.script_editor_dialog.set_selected_script_id(id)
def open_file_naming_script_editor(self):
"""Open the file naming script editor / manager in a new window.
"""
self.examples = ScriptEditorExamples(tagger=self.tagger)
self.script_editor_dialog = ScriptEditorDialog.show_instance(parent=self, examples=self.examples)
self.script_editor_dialog.signal_save.connect(self.script_editor_save)
self.script_editor_dialog.signal_selection_changed.connect(self.update_selector_from_script_editor)
self.script_editor_dialog.signal_index_changed.connect(self.script_editor_index_changed)
self.script_editor_dialog.finished.connect(self.script_editor_closed)
# Create list of signals to disconnect when opening Options dialog.
# Do not include `finished` because that is still used to clean up
# locally when the editor is closed from the Options dialog.
self.script_editor_signals = [
self.script_editor_dialog.signal_save,
self.script_editor_dialog.signal_selection_changed,
self.script_editor_dialog.signal_index_changed,
]
self.show_script_editor_action.setEnabled(False)
def script_editor_save(self):
"""Process "signal_save" signal from the script editor.
"""
self.make_script_selector_menu()
def script_editor_closed(self):
"""Process "finished" signal from the script editor.
"""
self.show_script_editor_action.setEnabled(True)
self.script_editor_dialog = None
self.make_script_selector_menu()
def update_script_editor_example_files(self):
"""Update the list of example files for the file naming script editor.
"""
if self.examples:
self.examples.update_sample_example_files()
self.update_script_editor_examples()
def update_script_editor_examples(self):
"""Update the examples for the file naming script editor, using current settings.
"""
if self.examples:
config = get_config()
override = {
"rename_files": config.setting["rename_files"],
"move_files": config.setting["move_files"],
}
self.examples.update_examples(override=override)
if self.script_editor_dialog:
self.script_editor_dialog.display_examples()
def script_editor_index_changed(self):
"""Process "signal_index_changed" signal from the script editor.
"""
self.script_editor_save()
def update_selector_from_script_editor(self):
"""Process "signal_selection_changed" signal from the script editor.
"""
self.script_editor_save()
def make_profile_selector_menu(self):
"""Update the sub-menu of available option profiles.
"""
config = get_config()
option_profiles = config.profiles[SettingConfigSection.PROFILES_KEY]
if not option_profiles:
self.profile_quick_selector_menu.setDisabled(True)
return
self.profile_quick_selector_menu.setDisabled(False)
self.profile_quick_selector_menu.clear()
group = QtWidgets.QActionGroup(self.profile_quick_selector_menu)
group.setExclusive(False)
def _add_menu_item(title, enabled, profile_id):
profile_action = QtWidgets.QAction(title, self.profile_quick_selector_menu)
profile_action.triggered.connect(partial(self.update_profile_selection, profile_id))
profile_action.setCheckable(True)
profile_action.setChecked(enabled)
self.profile_quick_selector_menu.addAction(profile_action)
group.addAction(profile_action)
for profile in option_profiles:
_add_menu_item(profile['title'], profile['enabled'], profile['id'])
def update_profile_selection(self, profile_id):
"""Toggle the enabled state of the selected profile.
Args:
profile_id (str): ID code of the profile to modify
"""
config = get_config()
option_profiles = config.profiles[SettingConfigSection.PROFILES_KEY]
for profile in option_profiles:
if profile['id'] == profile_id:
profile['enabled'] = not profile['enabled']
self.make_script_selector_menu()
return
def update_last_check_date(is_success):
if is_success:
config = get_config()
config.persist['last_update_check'] = datetime.date.today().toordinal()
else:
log.debug('The update check was unsuccessful. The last update date will not be changed.')