diff --git a/picard/tagger.py b/picard/tagger.py index 8ef2c6583..8caf31101 100644 --- a/picard/tagger.py +++ b/picard/tagger.py @@ -83,11 +83,10 @@ from picard.util import ( ) from picard.webservice import WebService from picard.webservice.api_helpers import MBAPIHelper, AcoustIdAPIHelper -from picard.ui.searchdialog import ( - TrackSearchDialog, - AlbumSearchDialog, - ArtistSearchDialog -) +from picard.ui.searchdialog.artist import ArtistSearchDialog +from picard.ui.searchdialog.track import TrackSearchDialog +from picard.ui.searchdialog.album import AlbumSearchDialog + class Tagger(QtWidgets.QApplication): diff --git a/picard/ui/mainwindow.py b/picard/ui/mainwindow.py index faa53bda5..2e9387b4a 100644 --- a/picard/ui/mainwindow.py +++ b/picard/ui/mainwindow.py @@ -37,9 +37,8 @@ from picard.ui.infodialog import FileInfoDialog, AlbumInfoDialog, TrackInfoDialo from picard.ui.infostatus import InfoStatus from picard.ui.passworddialog import PasswordDialog, ProxyDialog from picard.ui.logview import LogView, HistoryView -from picard.ui.searchdialog import ( - TrackSearchDialog, - AlbumSearchDialog) +from picard.ui.searchdialog.track import TrackSearchDialog +from picard.ui.searchdialog.album import AlbumSearchDialog from picard.ui.util import ( find_starting_directory, ButtonLineEdit, diff --git a/picard/ui/searchdialog.py b/picard/ui/searchdialog.py deleted file mode 100644 index 4ec4bf123..000000000 --- a/picard/ui/searchdialog.py +++ /dev/null @@ -1,935 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Picard, the next-generation MusicBrainz tagger -# Copyright (C) 2016 Rahul Raturi -# -# 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 PyQt5 import QtGui, QtCore, QtNetwork, QtWidgets -from PyQt5.QtCore import pyqtSignal -from operator import itemgetter -from functools import partial -from collections import namedtuple, OrderedDict -from picard import config, log -from picard.file import File -from picard.ui import PicardDialog -from picard.ui.util import StandardButton, ButtonLineEdit -from picard.util import icontheme, load_json, throttle -from picard.mbjson import ( - artist_to_metadata, - recording_to_metadata, - release_to_metadata, - release_group_to_metadata, - media_formats_from_node, - country_list_from_node -) -from picard.metadata import Metadata -from picard.webservice.api_helpers import escape_lucene_query -from picard.track import Track -from picard.const import CAA_HOST, CAA_PORT, QUERY_LIMIT -from picard.coverart.image import CaaThumbnailCoverArtImage - - -class ResultTable(QtWidgets.QTableWidget): - - def __init__(self, parent, column_titles): - super().__init__(0, len(column_titles), parent) - self.setHorizontalHeaderLabels(column_titles) - self.setSelectionMode( - QtWidgets.QAbstractItemView.SingleSelection) - self.setSelectionBehavior( - QtWidgets.QAbstractItemView.SelectRows) - self.setEditTriggers( - QtWidgets.QAbstractItemView.NoEditTriggers) - self.horizontalHeader().setStretchLastSection(True) - self.horizontalHeader().setSectionResizeMode( - QtWidgets.QHeaderView.Stretch) - self.horizontalHeader().setSectionResizeMode( - QtWidgets.QHeaderView.Interactive) - #only emit scrolled signal once per second - @throttle(1000) - def emit_scrolled(x): - parent.scrolled.emit() - self.horizontalScrollBar().valueChanged.connect(emit_scrolled) - self.verticalScrollBar().valueChanged.connect(emit_scrolled) - - -class SearchBox(QtWidgets.QWidget): - - def __init__(self, parent): - super().__init__(parent) - self.search_action = QtWidgets.QAction(icontheme.lookup('system-search'), - _("Search"), self) - self.search_action.setEnabled(False) - self.search_action.triggered.connect(self.search) - self.setupUi() - - def focus_in_event(self, event): - # When focus is on search edit box (ButtonLineEdit), need to disable - # dialog's accept button. This would avoid closing of dialog when user - # hits enter. - parent = self.parent() - if parent.table: - parent.table.clearSelection() - parent.accept_button.setEnabled(False) - - def setupUi(self): - self.layout = QtWidgets.QVBoxLayout(self) - self.search_row_widget = QtWidgets.QWidget(self) - self.search_row_layout = QtWidgets.QHBoxLayout(self.search_row_widget) - self.search_row_layout.setContentsMargins(1, 1, 1, 1) - self.search_row_layout.setSpacing(1) - self.search_edit = ButtonLineEdit(self.search_row_widget) - self.search_edit.returnPressed.connect(self.trigger_search_action) - self.search_edit.textChanged.connect(self.enable_search) - self.search_edit.setFocusPolicy(QtCore.Qt.StrongFocus) - self.search_edit.focusInEvent = self.focus_in_event - self.search_row_layout.addWidget(self.search_edit) - self.search_button = QtWidgets.QToolButton(self.search_row_widget) - self.search_button.setAutoRaise(True) - self.search_button.setDefaultAction(self.search_action) - self.search_button.setIconSize(QtCore.QSize(22, 22)) - self.search_row_layout.addWidget(self.search_button) - self.search_row_widget.setLayout(self.search_row_layout) - self.layout.addWidget(self.search_row_widget) - self.adv_opt_row_widget = QtWidgets.QWidget(self) - self.adv_opt_row_layout = QtWidgets.QHBoxLayout(self.adv_opt_row_widget) - self.adv_opt_row_layout.setAlignment(QtCore.Qt.AlignLeft) - self.adv_opt_row_layout.setContentsMargins(1, 1, 1, 1) - self.adv_opt_row_layout.setSpacing(1) - self.use_adv_search_syntax = QtWidgets.QCheckBox(self.adv_opt_row_widget) - self.use_adv_search_syntax.setText(_("Use advanced query syntax")) - self.use_adv_search_syntax.stateChanged.connect(self.update_advanced_syntax_setting) - self.adv_opt_row_layout.addWidget(self.use_adv_search_syntax) - self.adv_syntax_help = QtWidgets.QLabel(self.adv_opt_row_widget) - self.adv_syntax_help.setOpenExternalLinks(True) - self.adv_syntax_help.setText(_( - " (" - "Syntax Help)")) - self.adv_opt_row_layout.addWidget(self.adv_syntax_help) - self.adv_opt_row_widget.setLayout(self.adv_opt_row_layout) - self.layout.addWidget(self.adv_opt_row_widget) - self.layout.setContentsMargins(1, 1, 1, 1) - self.layout.setSpacing(1) - self.setMaximumHeight(60) - - def search(self): - self.parent().search(self.search_edit.text()) - - def restore_checkbox_state(self): - self.use_adv_search_syntax.setChecked(config.setting["use_adv_search_syntax"]) - - def update_advanced_syntax_setting(self): - config.setting["use_adv_search_syntax"] = self.use_adv_search_syntax.isChecked() - - def enable_search(self): - if self.search_edit.text(): - self.search_action.setEnabled(True) - else: - self.search_action.setEnabled(False) - - def trigger_search_action(self): - if self.search_action.isEnabled(): - self.search_action.trigger() - - -class CoverWidget(QtWidgets.QWidget): - - shown = pyqtSignal() - - def __init__(self, parent, width=100, height=100): - super().__init__(parent) - self.layout = QtWidgets.QVBoxLayout(self) - self.layout.setContentsMargins(0, 0, 0, 0) - self.layout.setAlignment(QtCore.Qt.AlignCenter) - self.loading_gif_label = QtWidgets.QLabel(self) - self.loading_gif_label.setAlignment(QtCore.Qt.AlignCenter) - loading_gif = QtGui.QMovie(":/images/loader.gif") - self.loading_gif_label.setMovie(loading_gif) - loading_gif.start() - self.layout.addWidget(self.loading_gif_label) - self.__sizehint = self.__size = QtCore.QSize(width, height) - self.setStyleSheet("padding: 0") - - def set_pixmap(self, pixmap): - wid = self.layout.takeAt(0) - if wid: - wid.widget().deleteLater() - cover_label = QtWidgets.QLabel(self) - pixmap = pixmap.scaled(self.__size, QtCore.Qt.KeepAspectRatio, - QtCore.Qt.SmoothTransformation) - self.__sizehint = pixmap.size() - cover_label.setPixmap(pixmap) - self.layout.addWidget(cover_label) - - def not_found(self): - """Update the widget with a blank image.""" - shadow = QtGui.QPixmap(":/images/CoverArtShadow.png") - self.set_pixmap(shadow) - - def sizeHint(self): - return self.__sizehint - - def showEvent(self, event): - super().showEvent(event) - self.shown.emit() - - -Retry = namedtuple("Retry", ["function", "query"]) - - -class SearchDialog(PicardDialog): - - scrolled = pyqtSignal() - - def __init__(self, parent, accept_button_title, show_search=True): - super().__init__(parent) - self.search_results = [] - self.table = None - self.show_search = show_search - self.search_box = None - self.setupUi(accept_button_title) - self.restore_state() - # self.columns has to be an ordered dict, with column name as keys, and - # matching label as values - self.columns = None - - @property - def columns(self): - return self.__columns - - @columns.setter - def columns(self, list_of_tuples): - if not list_of_tuples: - list_of_tuples = [] - self.__columns = OrderedDict(list_of_tuples) - self.__colkeys = list(self.columns.keys()) - - @property - def table_headers(self): - return list(self.columns.values()) - - def colpos(self, colname): - return self.__colkeys.index(colname) - - def set_table_item(self, row, colname, obj, key, default="", conv=None): - item = QtWidgets.QTableWidgetItem() - # QVariant remembers the original type of the data - # matching comparison operator will be used when sorting - # get() will return a string, force conversion if asked to - value = obj.get(key, default) - if conv is not None: - value = conv(value) - item.setData(QtCore.Qt.EditRole, value) - self.table.setItem(row, self.colpos(colname), item) - - def setupUi(self, accept_button_title): - self.verticalLayout = QtWidgets.QVBoxLayout(self) - self.verticalLayout.setObjectName(_("vertical_layout")) - if self.show_search: - self.search_box = SearchBox(self) - self.search_box.setObjectName(_("search_box")) - self.verticalLayout.addWidget(self.search_box) - self.center_widget = QtWidgets.QWidget(self) - self.center_widget.setObjectName(_("center_widget")) - self.center_layout = QtWidgets.QVBoxLayout(self.center_widget) - self.center_layout.setObjectName(_("center_layout")) - self.center_layout.setContentsMargins(1, 1, 1, 1) - self.center_widget.setLayout(self.center_layout) - self.verticalLayout.addWidget(self.center_widget) - self.buttonBox = QtWidgets.QDialogButtonBox(self) - self.accept_button = QtWidgets.QPushButton( - accept_button_title, - self.buttonBox) - self.accept_button.setEnabled(False) - self.buttonBox.addButton( - self.accept_button, - QtWidgets.QDialogButtonBox.AcceptRole) - self.buttonBox.addButton( - StandardButton(StandardButton.CANCEL), - QtWidgets.QDialogButtonBox.RejectRole) - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) - self.verticalLayout.addWidget(self.buttonBox) - - def add_widget_to_center_layout(self, widget): - """Update center widget with new child. If child widget exists, - schedule it for deletion.""" - wid = self.center_layout.takeAt(0) - if wid: - if wid.widget().objectName() == "results_table": - self.table = None - wid.widget().deleteLater() - self.center_layout.addWidget(widget) - - def show_progress(self): - self.progress_widget = QtWidgets.QWidget(self) - self.progress_widget.setObjectName("progress_widget") - layout = QtWidgets.QVBoxLayout(self.progress_widget) - text_label = QtWidgets.QLabel(_('Loading...'), self.progress_widget) - text_label.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignBottom) - gif_label = QtWidgets.QLabel(self.progress_widget) - movie = QtGui.QMovie(":/images/loader.gif") - gif_label.setMovie(movie) - movie.start() - gif_label.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignTop) - layout.addWidget(text_label) - layout.addWidget(gif_label) - layout.setContentsMargins(1, 1, 1, 1) - self.progress_widget.setLayout(layout) - self.add_widget_to_center_layout(self.progress_widget) - - def show_error(self, error, show_retry_button=False): - """Display the error string. - - Args: - error -- Error string - show_retry_button -- Whether to display retry button or not - """ - self.error_widget = QtWidgets.QWidget(self) - self.error_widget.setObjectName("error_widget") - layout = QtWidgets.QVBoxLayout(self.error_widget) - error_label = QtWidgets.QLabel(error, self.error_widget) - error_label.setWordWrap(True) - error_label.setAlignment(QtCore.Qt.AlignCenter) - error_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) - layout.addWidget(error_label) - if show_retry_button: - retry_widget = QtWidgets.QWidget(self.error_widget) - retry_layout = QtWidgets.QHBoxLayout(retry_widget) - retry_button = QtWidgets.QPushButton(_("Retry"), self.error_widget) - retry_button.clicked.connect(self.retry) - retry_button.setSizePolicy(QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Fixed)) - retry_layout.addWidget(retry_button) - retry_layout.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignTop) - retry_widget.setLayout(retry_layout) - layout.addWidget(retry_widget) - self.error_widget.setLayout(layout) - self.add_widget_to_center_layout(self.error_widget) - - def prepare_table(self): - self.table = ResultTable(self, self.table_headers) - self.table.verticalHeader().setDefaultSectionSize(100) - self.table.setSortingEnabled(False) - self.table.setObjectName("results_table") - self.table.cellDoubleClicked.connect(self.accept) - self.restore_table_header_state() - self.add_widget_to_center_layout(self.table) - - def enable_accept_button(): - self.accept_button.setEnabled(True) - self.table.itemSelectionChanged.connect( - enable_accept_button) - - def show_table(self, sort_column=None, sort_order=QtCore.Qt.DescendingOrder): - self.table.setSortingEnabled(True) - if sort_column: - self.table.sortItems(self.colpos(sort_column), sort_order) - self.table.resizeColumnsToContents() - self.table.resizeRowsToContents() - - def network_error(self, reply, error): - error_msg = _("Following error occurred while fetching results:

" - "Network request error for %s:
%s (QT code %d, HTTP code %s)
") % ( - reply.request().url().toString(QtCore.QUrl.RemoveUserInfo), - reply.errorString(), - error, - repr(reply.attribute( - QtNetwork.QNetworkRequest.HttpStatusCodeAttribute)) - ) - self.show_error(error_msg, show_retry_button=True) - - def no_results_found(self): - error_msg = _("No results found. Please try a different search query.") - self.show_error(error_msg) - - def accept(self): - if self.table: - row = self.table.selectionModel().selectedRows()[0].row() - self.accept_event(row) - self.save_state() - QtWidgets.QDialog.accept(self) - - def reject(self): - self.save_state() - QtWidgets.QDialog.reject(self) - - def restore_state(self): - size = config.persist[self.dialog_window_size] - if size: - self.resize(size) - if self.show_search: - self.search_box.restore_checkbox_state() - log.debug("restore_state: %s" % self.dialog_window_size) - - def restore_table_header_state(self): - header = self.table.horizontalHeader() - state = config.persist[self.dialog_header_state] - if state: - header.restoreState(state) - header.setSectionResizeMode(QtWidgets.QHeaderView.Interactive) - log.debug("restore_state: %s" % self.dialog_header_state) - - def save_state(self): - if self.table: - self.save_table_header_state() - config.persist[self.dialog_window_size] = self.size() - log.debug("save_state: %s" % self.dialog_window_size) - - def save_table_header_state(self): - state = self.table.horizontalHeader().saveState() - config.persist[self.dialog_header_state] = state - log.debug("save_state: %s" % self.dialog_header_state) - - def search_box_text(self, text): - if self.search_box: - self.search_box.search_edit.setText(text) - - -class TrackSearchDialog(SearchDialog): - - dialog_window_size = "tracksearchdialog_window_size" - dialog_header_state = "tracksearchdialog_header_state" - - options = [ - config.Option("persist", dialog_window_size, QtCore.QSize(720, 360)), - config.Option("persist", dialog_header_state, QtCore.QByteArray()) - ] - - def __init__(self, parent): - super().__init__( - parent, - accept_button_title=_("Load into Picard")) - self.file_ = None - self.setWindowTitle(_("Track Search Results")) - self.columns = [ - ('name', _("Name")), - ('length', _("Length")), - ('artist', _("Artist")), - ('release', _("Release")), - ('date', _("Date")), - ('country', _("Country")), - ('type', _("Type")), - ('score', _("Score")), - ] - - def search(self, text): - """Perform search using query provided by the user.""" - self.retry_params = Retry(self.search, text) - self.search_box_text(text) - self.show_progress() - self.tagger.mb_api.find_tracks(self.handle_reply, - query=text, - search=True, - limit=QUERY_LIMIT) - - def load_similar_tracks(self, file_): - """Perform search using existing metadata information - from the file as query.""" - self.retry_params = Retry(self.load_similar_tracks, file_) - self.file_ = file_ - metadata = file_.orig_metadata - query = { - 'track': metadata['title'], - 'artist': metadata['artist'], - 'release': metadata['album'], - 'tnum': metadata['tracknumber'], - 'tracks': metadata['totaltracks'], - 'qdur': string_(metadata.length // 2000), - 'isrc': metadata['isrc'], - } - - # Generate query to be displayed to the user (in search box). - # If advanced query syntax setting is enabled by user, display query in - # advanced syntax style. Otherwise display only track title. - if config.setting["use_adv_search_syntax"]: - query_str = ' '.join(['%s:(%s)' % (item, escape_lucene_query(value)) - for item, value in query.items() if value]) - else: - query_str = query["track"] - - query["limit"] = QUERY_LIMIT - self.search_box_text(query_str) - self.show_progress() - self.tagger.mb_api.find_tracks( - self.handle_reply, - **query) - - def retry(self): - self.retry_params.function(self.retry_params.query) - - def handle_reply(self, document, http, error): - if error: - self.network_error(http, error) - return - - try: - tracks = document['recordings'] - except (KeyError, TypeError): - self.no_results_found() - return - - if self.file_: - sorted_results = sorted( - (self.file_.orig_metadata.compare_to_track( - track, - File.comparison_weights) - for track in tracks), - reverse=True, - key=itemgetter(0)) - tracks = [item[3] for item in sorted_results] - - del self.search_results[:] # Clear existing data - self.parse_tracks(tracks) - self.display_results() - - def display_results(self): - self.prepare_table() - for row, obj in enumerate(self.search_results): - track = obj[0] - self.table.insertRow(row) - self.set_table_item(row, 'name', track, "title") - self.set_table_item(row, 'length', track, "~length") - self.set_table_item(row, 'artist', track, "artist") - self.set_table_item(row, 'release', track, "album") - self.set_table_item(row, 'date', track, "date") - self.set_table_item(row, 'country', track, "country") - self.set_table_item(row, 'type', track, "releasetype") - self.set_table_item(row, 'score', track, "score", conv=int) - self.show_table(sort_column='score') - - def parse_tracks(self, tracks): - for node in tracks: - if "releases" in node: - for rel_node in node['releases']: - track = Metadata() - recording_to_metadata(node, track) - track['score'] = node['score'] - release_to_metadata(rel_node, track) - rg_node = rel_node['release-group'] - release_group_to_metadata(rg_node, track) - countries = country_list_from_node(rel_node) - if countries: - track["country"] = ", ".join(countries) - self.search_results.append((track, node)) - else: - # This handles the case when no release is associated with a track - # i.e. the track is an NAT - track = Metadata() - recording_to_metadata(node, track) - track['score'] = node['score'] - track["album"] = _("Standalone Recording") - self.search_results.append((track, node)) - - def accept_event(self, arg): - self.load_selection(arg) - - def load_selection(self, row): - """Load the album corresponding to the selected track. - If the search is performed for a file, also associate the file to - corresponding track in the album. - """ - - track, node = self.search_results[row] - if track.get("musicbrainz_albumid"): - # The track is not an NAT - self.tagger.get_release_group_by_id(track["musicbrainz_releasegroupid"]).loaded_albums.add( - track["musicbrainz_albumid"]) - if self.file_: - # Search is performed for a file. - # Have to move that file from its existing album to the new one. - if isinstance(self.file_.parent, Track): - album = self.file_.parent.album - self.tagger.move_file_to_track(self.file_, track["musicbrainz_albumid"], track["musicbrainz_recordingid"]) - if album._files == 0: - # Remove album if it has no more files associated - self.tagger.remove_album(album) - else: - self.tagger.move_file_to_track(self.file_, track["musicbrainz_albumid"], track["musicbrainz_recordingid"]) - else: - # No files associated. Just a normal search. - self.tagger.load_album(track["musicbrainz_albumid"]) - else: - if self.file_: - album = self.file_.parent.album - self.tagger.move_file_to_nat(track["musicbrainz_recordingid"]) - if album._files == 0: - self.tagger.remove_album(album) - else: - self.tagger.load_nat(track["musicbrainz_recordingid"], node) - - -class CoverCell: - - def __init__(self, parent, release, row, colname, on_show=None): - self.parent = parent - self.release = release - self.fetched = False - self.fetch_task = None - self.row = row - self.column = self.parent.colpos(colname) - widget = CoverWidget(self.parent.table) - if on_show is not None: - widget.shown.connect(partial(on_show, self)) - self.parent.table.setCellWidget(row, self.column, widget) - - def widget(self): - if not self.parent.table: - return None - return self.parent.table.cellWidget(self.row, self.column) - - def is_visible(self): - widget = self.widget() - if not widget: - return False - return not widget.visibleRegion().isEmpty() - - def set_pixmap(self, pixmap): - widget = self.widget() - if widget: - widget.set_pixmap(pixmap) - - def not_found(self): - widget = self.widget() - if widget: - widget.not_found() - - -class AlbumSearchDialog(SearchDialog): - - dialog_window_size = "albumsearchdialog_window_size" - dialog_header_state = "albumsearchdialog_header_state" - - options = [ - config.Option("persist", dialog_window_size, QtCore.QSize(720, 360)), - config.Option("persist", dialog_header_state, QtCore.QByteArray()) - ] - - def __init__(self, parent): - super().__init__( - parent, - accept_button_title=_("Load into Picard")) - self.cluster = None - self.setWindowTitle(_("Album Search Results")) - self.columns = [ - ('name', _("Name")), - ('artist', _("Artist")), - ('format', _("Format")), - ('tracks', _("Tracks")), - ('date', _("Date")), - ('country', _("Country")), - ('labels', _("Labels")), - ('catnums', _("Catalog #s")), - ('barcode', _("Barcode")), - ('language', _("Language")), - ('type', _("Type")), - ('status', _("Status")), - ('cover', _("Cover")), - ('score', _("Score")), - ] - self.cover_cells = [] - self.fetching = False - self.scrolled.connect(self.fetch_coverarts) - - def search(self, text): - """Perform search using query provided by the user.""" - self.retry_params = Retry(self.search, text) - self.search_box_text(text) - self.show_progress() - self.tagger.mb_api.find_releases(self.handle_reply, - query=text, - search=True, - limit=QUERY_LIMIT) - - def show_similar_albums(self, cluster): - """Perform search by using existing metadata information - from the cluster as query.""" - self.retry_params = Retry(self.show_similar_albums, cluster) - self.cluster = cluster - metadata = cluster.metadata - query = { - "artist": metadata["albumartist"], - "release": metadata["album"], - "tracks": string_(len(cluster.files)) - } - - # Generate query to be displayed to the user (in search box). - # If advanced query syntax setting is enabled by user, display query in - # advanced syntax style. Otherwise display only album title. - if config.setting["use_adv_search_syntax"]: - query_str = ' '.join(['%s:(%s)' % (item, escape_lucene_query(value)) - for item, value in query.items() if value]) - else: - query_str = query["release"] - - query["limit"] = QUERY_LIMIT - self.search_box_text(query_str) - self.show_progress() - self.tagger.mb_api.find_releases( - self.handle_reply, - **query) - - def retry(self): - self.retry_params.function(self.retry_params.query) - - def handle_reply(self, document, http, error): - if error: - self.network_error(http, error) - return - - try: - releases = document['releases'] - except (KeyError, TypeError): - self.no_results_found() - return - - del self.search_results[:] - self.parse_releases(releases) - self.display_results() - self.fetch_coverarts() - - def fetch_coverarts(self): - if self.fetching: - return - self.fetching = True - for cell in self.cover_cells: - self.fetch_coverart(cell) - self.fetching = False - - def fetch_coverart(self, cell): - """Queue cover art jsons from CAA server for each album in search - results. - """ - if cell.fetched: - return - if not cell.is_visible(): - return - cell.fetched = True - caa_path = "/release/%s" % cell.release["musicbrainz_albumid"] - cell.fetch_task = self.tagger.webservice.download( - CAA_HOST, - CAA_PORT, - caa_path, - partial(self._caa_json_downloaded, cell) - ) - - def _caa_json_downloaded(self, cover_cell, data, http, error): - """Handle json reply from CAA server. - If server replies without error, try to get small thumbnail of front - coverart of the release. - """ - if not self.table: - return - - cover_cell.fetch_task = None - - if error: - cover_cell.not_found() - return - - try: - caa_data = load_json(data) - except ValueError: - cover_cell.not_found() - return - - front = None - for image in caa_data["images"]: - if image["front"]: - front = image - break - - if front: - url = front["thumbnails"]["small"] - coverartimage = CaaThumbnailCoverArtImage(url=url) - cover_cell.fetch_task = self.tagger.webservice.download( - coverartimage.host, - coverartimage.port, - coverartimage.path, - partial(self._cover_downloaded, cover_cell), - ) - else: - cover_cell.not_found() - - def _cover_downloaded(self, cover_cell, data, http, error): - """Handle cover art query reply from CAA server. - If server returns the cover image successfully, update the cover art - cell of particular release. - - Args: - row -- Album's row in results table - """ - if not self.table: - return - - cover_cell.fetch_task = None - - if error: - cover_cell.not_found() - else: - pixmap = QtGui.QPixmap() - try: - pixmap.loadFromData(data) - cover_cell.set_pixmap(pixmap) - except Exception as e: - cover_cell.not_found() - log.error(e) - - def fetch_cleanup(self): - for cell in self.cover_cells: - if cell.fetch_task is not None: - log.debug("Removing cover art fetch task for %s", - cell.release['musicbrainz_albumid']) - self.tagger.webservice.remove_task(cell.fetch_task) - - def closeEvent(self, event): - if self.cover_cells: - self.fetch_cleanup() - super().closeEvent(event) - - def parse_releases(self, releases): - for node in releases: - release = Metadata() - release_to_metadata(node, release) - release['score'] = node['score'] - rg_node = node['release-group'] - release_group_to_metadata(rg_node, release) - if "media" in node: - media = node['media'] - release["format"] = media_formats_from_node(media) - release["tracks"] = node['track-count'] - countries = country_list_from_node(node) - if countries: - release["country"] = ", ".join(countries) - self.search_results.append(release) - - def display_results(self): - self.prepare_table() - self.cover_cells = [] - for row, release in enumerate(self.search_results): - self.table.insertRow(row) - self.set_table_item(row, 'name', release, "album") - self.set_table_item(row, 'artist', release, "albumartist") - self.set_table_item(row, 'format', release, "format") - self.set_table_item(row, 'tracks', release, "tracks") - self.set_table_item(row, 'date', release, "date") - self.set_table_item(row, 'country', release, "country") - self.set_table_item(row, 'labels', release, "label") - self.set_table_item(row, 'catnums', release, "catalognumber") - self.set_table_item(row, 'barcode', release, "barcode") - self.set_table_item(row, 'language', release, "~releaselanguage") - self.set_table_item(row, 'type', release, "releasetype") - self.set_table_item(row, 'status', release, "releasestatus") - self.set_table_item(row, 'score', release, "score", conv=int) - self.cover_cells.append(CoverCell(self, release, row, 'cover', - on_show=self.fetch_coverart)) - self.show_table(sort_column='score') - - def accept_event(self, arg): - self.load_selection(arg) - - def load_selection(self, row): - release = self.search_results[row] - self.tagger.get_release_group_by_id( - release["musicbrainz_releasegroupid"]).loaded_albums.add( - release["musicbrainz_albumid"]) - album = self.tagger.load_album(release["musicbrainz_albumid"]) - if self.cluster: - files = self.tagger.get_files_from_objects([self.cluster]) - self.tagger.move_files_to_album(files, release["musicbrainz_albumid"], - album) - - -class ArtistSearchDialog(SearchDialog): - - dialog_window_size = "artistsearchdialog_window_size" - dialog_header_state = "artistsearchdialog_header_state" - - options = [ - config.Option("persist", dialog_window_size, QtCore.QSize(720, 360)), - config.Option("persist", dialog_header_state, QtCore.QByteArray()) - ] - - def __init__(self, parent): - super().__init__( - parent, - accept_button_title=_("Show in browser")) - self.setWindowTitle(_("Artist Search Dialog")) - self.columns = [ - ('name', _("Name")), - ('type', _("Type")), - ('gender', _("Gender")), - ('area', _("Area")), - ('begindate', _("Begin")), - ('beginarea', _("Begin Area")), - ('enddate', _("End")), - ('endarea', _("End Area")), - ('score', _("Score")), - ] - - def search(self, text): - self.retry_params = (self.search, text) - self.search_box_text(text) - self.show_progress() - self.tagger.mb_api.find_artists(self.handle_reply, - query=text, - search=True, - limit=QUERY_LIMIT) - - def retry(self): - self.retry_params[0](self.retry_params[1]) - - def handle_reply(self, document, http, error): - if error: - self.network_error(http, error) - return - - try: - artists = document['artists'] - except (KeyError, TypeError): - self.no_results() - return - - del self.search_results[:] - self.parse_artists(artists) - self.display_results() - - def parse_artists(self, artists): - for node in artists: - artist = Metadata() - artist_to_metadata(node, artist) - artist['score'] = node['score'] - self.search_results.append(artist) - - def display_results(self): - self.prepare_table() - for row, artist in enumerate(self.search_results): - self.table.insertRow(row) - self.set_table_item(row, 'name', artist, "name") - self.set_table_item(row, 'type', artist, "type") - self.set_table_item(row, 'gender', artist, "gender") - self.set_table_item(row, 'area', artist, "area") - self.set_table_item(row, 'begindate', artist, "begindate") - self.set_table_item(row, 'beginarea', artist, "beginarea") - self.set_table_item(row, 'enddate', artist, "enddate") - self.set_table_item(row, 'endarea', artist, "endarea") - self.set_table_item(row, 'score', artist, "score", conv=int) - self.show_table(sort_column='score') - - def accept_event(self, row): - self.load_in_browser(row) - - def load_in_browser(self, row): - self.tagger.search(self.search_results[row]["musicbrainz_artistid"], "artist") diff --git a/picard/ui/searchdialog/__init__.py b/picard/ui/searchdialog/__init__.py new file mode 100644 index 000000000..349f3f06a --- /dev/null +++ b/picard/ui/searchdialog/__init__.py @@ -0,0 +1,341 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# Copyright (C) 2016 Rahul Raturi +# Copyright (C) 2018 Laurent Monin +# +# 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 PyQt5 import QtGui, QtCore, QtNetwork, QtWidgets +from PyQt5.QtCore import pyqtSignal +from collections import namedtuple, OrderedDict +from picard import config, log +from picard.ui import PicardDialog +from picard.ui.util import StandardButton, ButtonLineEdit +from picard.util import icontheme, throttle + + +class ResultTable(QtWidgets.QTableWidget): + + def __init__(self, parent, column_titles): + super().__init__(0, len(column_titles), parent) + self.setHorizontalHeaderLabels(column_titles) + self.setSelectionMode( + QtWidgets.QAbstractItemView.SingleSelection) + self.setSelectionBehavior( + QtWidgets.QAbstractItemView.SelectRows) + self.setEditTriggers( + QtWidgets.QAbstractItemView.NoEditTriggers) + self.horizontalHeader().setStretchLastSection(True) + self.horizontalHeader().setSectionResizeMode( + QtWidgets.QHeaderView.Stretch) + self.horizontalHeader().setSectionResizeMode( + QtWidgets.QHeaderView.Interactive) + #only emit scrolled signal once per second + @throttle(1000) + def emit_scrolled(x): + parent.scrolled.emit() + self.horizontalScrollBar().valueChanged.connect(emit_scrolled) + self.verticalScrollBar().valueChanged.connect(emit_scrolled) + + +class SearchBox(QtWidgets.QWidget): + + def __init__(self, parent): + super().__init__(parent) + self.search_action = QtWidgets.QAction(icontheme.lookup('system-search'), + _("Search"), self) + self.search_action.setEnabled(False) + self.search_action.triggered.connect(self.search) + self.setupUi() + + def focus_in_event(self, event): + # When focus is on search edit box (ButtonLineEdit), need to disable + # dialog's accept button. This would avoid closing of dialog when user + # hits enter. + parent = self.parent() + if parent.table: + parent.table.clearSelection() + parent.accept_button.setEnabled(False) + + def setupUi(self): + self.layout = QtWidgets.QVBoxLayout(self) + self.search_row_widget = QtWidgets.QWidget(self) + self.search_row_layout = QtWidgets.QHBoxLayout(self.search_row_widget) + self.search_row_layout.setContentsMargins(1, 1, 1, 1) + self.search_row_layout.setSpacing(1) + self.search_edit = ButtonLineEdit(self.search_row_widget) + self.search_edit.returnPressed.connect(self.trigger_search_action) + self.search_edit.textChanged.connect(self.enable_search) + self.search_edit.setFocusPolicy(QtCore.Qt.StrongFocus) + self.search_edit.focusInEvent = self.focus_in_event + self.search_row_layout.addWidget(self.search_edit) + self.search_button = QtWidgets.QToolButton(self.search_row_widget) + self.search_button.setAutoRaise(True) + self.search_button.setDefaultAction(self.search_action) + self.search_button.setIconSize(QtCore.QSize(22, 22)) + self.search_row_layout.addWidget(self.search_button) + self.search_row_widget.setLayout(self.search_row_layout) + self.layout.addWidget(self.search_row_widget) + self.adv_opt_row_widget = QtWidgets.QWidget(self) + self.adv_opt_row_layout = QtWidgets.QHBoxLayout(self.adv_opt_row_widget) + self.adv_opt_row_layout.setAlignment(QtCore.Qt.AlignLeft) + self.adv_opt_row_layout.setContentsMargins(1, 1, 1, 1) + self.adv_opt_row_layout.setSpacing(1) + self.use_adv_search_syntax = QtWidgets.QCheckBox(self.adv_opt_row_widget) + self.use_adv_search_syntax.setText(_("Use advanced query syntax")) + self.use_adv_search_syntax.stateChanged.connect(self.update_advanced_syntax_setting) + self.adv_opt_row_layout.addWidget(self.use_adv_search_syntax) + self.adv_syntax_help = QtWidgets.QLabel(self.adv_opt_row_widget) + self.adv_syntax_help.setOpenExternalLinks(True) + self.adv_syntax_help.setText(_( + " (" + "Syntax Help)")) + self.adv_opt_row_layout.addWidget(self.adv_syntax_help) + self.adv_opt_row_widget.setLayout(self.adv_opt_row_layout) + self.layout.addWidget(self.adv_opt_row_widget) + self.layout.setContentsMargins(1, 1, 1, 1) + self.layout.setSpacing(1) + self.setMaximumHeight(60) + + def search(self): + self.parent().search(self.search_edit.text()) + + def restore_checkbox_state(self): + self.use_adv_search_syntax.setChecked(config.setting["use_adv_search_syntax"]) + + def update_advanced_syntax_setting(self): + config.setting["use_adv_search_syntax"] = self.use_adv_search_syntax.isChecked() + + def enable_search(self): + if self.search_edit.text(): + self.search_action.setEnabled(True) + else: + self.search_action.setEnabled(False) + + def trigger_search_action(self): + if self.search_action.isEnabled(): + self.search_action.trigger() + + +Retry = namedtuple("Retry", ["function", "query"]) + + +class SearchDialog(PicardDialog): + + scrolled = pyqtSignal() + + def __init__(self, parent, accept_button_title, show_search=True): + super().__init__(parent) + self.search_results = [] + self.table = None + self.show_search = show_search + self.search_box = None + self.setupUi(accept_button_title) + self.restore_state() + # self.columns has to be an ordered dict, with column name as keys, and + # matching label as values + self.columns = None + + @property + def columns(self): + return self.__columns + + @columns.setter + def columns(self, list_of_tuples): + if not list_of_tuples: + list_of_tuples = [] + self.__columns = OrderedDict(list_of_tuples) + self.__colkeys = list(self.columns.keys()) + + @property + def table_headers(self): + return list(self.columns.values()) + + def colpos(self, colname): + return self.__colkeys.index(colname) + + def set_table_item(self, row, colname, obj, key, default="", conv=None): + item = QtWidgets.QTableWidgetItem() + # QVariant remembers the original type of the data + # matching comparison operator will be used when sorting + # get() will return a string, force conversion if asked to + value = obj.get(key, default) + if conv is not None: + value = conv(value) + item.setData(QtCore.Qt.EditRole, value) + self.table.setItem(row, self.colpos(colname), item) + + def setupUi(self, accept_button_title): + self.verticalLayout = QtWidgets.QVBoxLayout(self) + self.verticalLayout.setObjectName(_("vertical_layout")) + if self.show_search: + self.search_box = SearchBox(self) + self.search_box.setObjectName(_("search_box")) + self.verticalLayout.addWidget(self.search_box) + self.center_widget = QtWidgets.QWidget(self) + self.center_widget.setObjectName(_("center_widget")) + self.center_layout = QtWidgets.QVBoxLayout(self.center_widget) + self.center_layout.setObjectName(_("center_layout")) + self.center_layout.setContentsMargins(1, 1, 1, 1) + self.center_widget.setLayout(self.center_layout) + self.verticalLayout.addWidget(self.center_widget) + self.buttonBox = QtWidgets.QDialogButtonBox(self) + self.accept_button = QtWidgets.QPushButton( + accept_button_title, + self.buttonBox) + self.accept_button.setEnabled(False) + self.buttonBox.addButton( + self.accept_button, + QtWidgets.QDialogButtonBox.AcceptRole) + self.buttonBox.addButton( + StandardButton(StandardButton.CANCEL), + QtWidgets.QDialogButtonBox.RejectRole) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + self.verticalLayout.addWidget(self.buttonBox) + + def add_widget_to_center_layout(self, widget): + """Update center widget with new child. If child widget exists, + schedule it for deletion.""" + wid = self.center_layout.takeAt(0) + if wid: + if wid.widget().objectName() == "results_table": + self.table = None + wid.widget().deleteLater() + self.center_layout.addWidget(widget) + + def show_progress(self): + self.progress_widget = QtWidgets.QWidget(self) + self.progress_widget.setObjectName("progress_widget") + layout = QtWidgets.QVBoxLayout(self.progress_widget) + text_label = QtWidgets.QLabel(_('Loading...'), self.progress_widget) + text_label.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignBottom) + gif_label = QtWidgets.QLabel(self.progress_widget) + movie = QtGui.QMovie(":/images/loader.gif") + gif_label.setMovie(movie) + movie.start() + gif_label.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignTop) + layout.addWidget(text_label) + layout.addWidget(gif_label) + layout.setContentsMargins(1, 1, 1, 1) + self.progress_widget.setLayout(layout) + self.add_widget_to_center_layout(self.progress_widget) + + def show_error(self, error, show_retry_button=False): + """Display the error string. + + Args: + error -- Error string + show_retry_button -- Whether to display retry button or not + """ + self.error_widget = QtWidgets.QWidget(self) + self.error_widget.setObjectName("error_widget") + layout = QtWidgets.QVBoxLayout(self.error_widget) + error_label = QtWidgets.QLabel(error, self.error_widget) + error_label.setWordWrap(True) + error_label.setAlignment(QtCore.Qt.AlignCenter) + error_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) + layout.addWidget(error_label) + if show_retry_button: + retry_widget = QtWidgets.QWidget(self.error_widget) + retry_layout = QtWidgets.QHBoxLayout(retry_widget) + retry_button = QtWidgets.QPushButton(_("Retry"), self.error_widget) + retry_button.clicked.connect(self.retry) + retry_button.setSizePolicy(QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Fixed)) + retry_layout.addWidget(retry_button) + retry_layout.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignTop) + retry_widget.setLayout(retry_layout) + layout.addWidget(retry_widget) + self.error_widget.setLayout(layout) + self.add_widget_to_center_layout(self.error_widget) + + def prepare_table(self): + self.table = ResultTable(self, self.table_headers) + self.table.verticalHeader().setDefaultSectionSize(100) + self.table.setSortingEnabled(False) + self.table.setObjectName("results_table") + self.table.cellDoubleClicked.connect(self.accept) + self.restore_table_header_state() + self.add_widget_to_center_layout(self.table) + + def enable_accept_button(): + self.accept_button.setEnabled(True) + self.table.itemSelectionChanged.connect( + enable_accept_button) + + def show_table(self, sort_column=None, sort_order=QtCore.Qt.DescendingOrder): + self.table.setSortingEnabled(True) + if sort_column: + self.table.sortItems(self.colpos(sort_column), sort_order) + self.table.resizeColumnsToContents() + self.table.resizeRowsToContents() + + def network_error(self, reply, error): + error_msg = _("Following error occurred while fetching results:

" + "Network request error for %s:
%s (QT code %d, HTTP code %s)
") % ( + reply.request().url().toString(QtCore.QUrl.RemoveUserInfo), + reply.errorString(), + error, + repr(reply.attribute( + QtNetwork.QNetworkRequest.HttpStatusCodeAttribute)) + ) + self.show_error(error_msg, show_retry_button=True) + + def no_results_found(self): + error_msg = _("No results found. Please try a different search query.") + self.show_error(error_msg) + + def accept(self): + if self.table: + row = self.table.selectionModel().selectedRows()[0].row() + self.accept_event(row) + self.save_state() + QtWidgets.QDialog.accept(self) + + def reject(self): + self.save_state() + QtWidgets.QDialog.reject(self) + + def restore_state(self): + size = config.persist[self.dialog_window_size] + if size: + self.resize(size) + if self.show_search: + self.search_box.restore_checkbox_state() + log.debug("restore_state: %s" % self.dialog_window_size) + + def restore_table_header_state(self): + header = self.table.horizontalHeader() + state = config.persist[self.dialog_header_state] + if state: + header.restoreState(state) + header.setSectionResizeMode(QtWidgets.QHeaderView.Interactive) + log.debug("restore_state: %s" % self.dialog_header_state) + + def save_state(self): + if self.table: + self.save_table_header_state() + config.persist[self.dialog_window_size] = self.size() + log.debug("save_state: %s" % self.dialog_window_size) + + def save_table_header_state(self): + state = self.table.horizontalHeader().saveState() + config.persist[self.dialog_header_state] = state + log.debug("save_state: %s" % self.dialog_header_state) + + def search_box_text(self, text): + if self.search_box: + self.search_box.search_edit.setText(text) diff --git a/picard/ui/searchdialog/album.py b/picard/ui/searchdialog/album.py new file mode 100644 index 000000000..fff4552ca --- /dev/null +++ b/picard/ui/searchdialog/album.py @@ -0,0 +1,360 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# Copyright (C) 2016 Rahul Raturi +# Copyright (C) 2018 Laurent Monin +# +# 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 PyQt5 import QtGui, QtCore, QtWidgets +from PyQt5.QtCore import pyqtSignal +from functools import partial +from picard import config, log +from picard.util import load_json +from picard.mbjson import ( + release_to_metadata, + release_group_to_metadata, + media_formats_from_node, + country_list_from_node +) +from picard.metadata import Metadata +from picard.webservice.api_helpers import escape_lucene_query +from picard.const import CAA_HOST, CAA_PORT, QUERY_LIMIT +from picard.coverart.image import CaaThumbnailCoverArtImage +from picard.ui.searchdialog import SearchDialog, Retry + + +class CoverWidget(QtWidgets.QWidget): + + shown = pyqtSignal() + + def __init__(self, parent, width=100, height=100): + super().__init__(parent) + self.layout = QtWidgets.QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setAlignment(QtCore.Qt.AlignCenter) + self.loading_gif_label = QtWidgets.QLabel(self) + self.loading_gif_label.setAlignment(QtCore.Qt.AlignCenter) + loading_gif = QtGui.QMovie(":/images/loader.gif") + self.loading_gif_label.setMovie(loading_gif) + loading_gif.start() + self.layout.addWidget(self.loading_gif_label) + self.__sizehint = self.__size = QtCore.QSize(width, height) + self.setStyleSheet("padding: 0") + + def set_pixmap(self, pixmap): + wid = self.layout.takeAt(0) + if wid: + wid.widget().deleteLater() + cover_label = QtWidgets.QLabel(self) + pixmap = pixmap.scaled(self.__size, QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation) + self.__sizehint = pixmap.size() + cover_label.setPixmap(pixmap) + self.layout.addWidget(cover_label) + + def not_found(self): + """Update the widget with a blank image.""" + shadow = QtGui.QPixmap(":/images/CoverArtShadow.png") + self.set_pixmap(shadow) + + def sizeHint(self): + return self.__sizehint + + def showEvent(self, event): + super().showEvent(event) + self.shown.emit() + + + +class CoverCell: + + def __init__(self, parent, release, row, colname, on_show=None): + self.parent = parent + self.release = release + self.fetched = False + self.fetch_task = None + self.row = row + self.column = self.parent.colpos(colname) + widget = CoverWidget(self.parent.table) + if on_show is not None: + widget.shown.connect(partial(on_show, self)) + self.parent.table.setCellWidget(row, self.column, widget) + + def widget(self): + if not self.parent.table: + return None + return self.parent.table.cellWidget(self.row, self.column) + + def is_visible(self): + widget = self.widget() + if not widget: + return False + return not widget.visibleRegion().isEmpty() + + def set_pixmap(self, pixmap): + widget = self.widget() + if widget: + widget.set_pixmap(pixmap) + + def not_found(self): + widget = self.widget() + if widget: + widget.not_found() + + +class AlbumSearchDialog(SearchDialog): + + dialog_window_size = "albumsearchdialog_window_size" + dialog_header_state = "albumsearchdialog_header_state" + + options = [ + config.Option("persist", dialog_window_size, QtCore.QSize(720, 360)), + config.Option("persist", dialog_header_state, QtCore.QByteArray()) + ] + + def __init__(self, parent): + super().__init__( + parent, + accept_button_title=_("Load into Picard")) + self.cluster = None + self.setWindowTitle(_("Album Search Results")) + self.columns = [ + ('name', _("Name")), + ('artist', _("Artist")), + ('format', _("Format")), + ('tracks', _("Tracks")), + ('date', _("Date")), + ('country', _("Country")), + ('labels', _("Labels")), + ('catnums', _("Catalog #s")), + ('barcode', _("Barcode")), + ('language', _("Language")), + ('type', _("Type")), + ('status', _("Status")), + ('cover', _("Cover")), + ('score', _("Score")), + ] + self.cover_cells = [] + self.fetching = False + self.scrolled.connect(self.fetch_coverarts) + + def search(self, text): + """Perform search using query provided by the user.""" + self.retry_params = Retry(self.search, text) + self.search_box_text(text) + self.show_progress() + self.tagger.mb_api.find_releases(self.handle_reply, + query=text, + search=True, + limit=QUERY_LIMIT) + + def show_similar_albums(self, cluster): + """Perform search by using existing metadata information + from the cluster as query.""" + self.retry_params = Retry(self.show_similar_albums, cluster) + self.cluster = cluster + metadata = cluster.metadata + query = { + "artist": metadata["albumartist"], + "release": metadata["album"], + "tracks": string_(len(cluster.files)) + } + + # Generate query to be displayed to the user (in search box). + # If advanced query syntax setting is enabled by user, display query in + # advanced syntax style. Otherwise display only album title. + if config.setting["use_adv_search_syntax"]: + query_str = ' '.join(['%s:(%s)' % (item, escape_lucene_query(value)) + for item, value in query.items() if value]) + else: + query_str = query["release"] + + query["limit"] = QUERY_LIMIT + self.search_box_text(query_str) + self.show_progress() + self.tagger.mb_api.find_releases( + self.handle_reply, + **query) + + def retry(self): + self.retry_params.function(self.retry_params.query) + + def handle_reply(self, document, http, error): + if error: + self.network_error(http, error) + return + + try: + releases = document['releases'] + except (KeyError, TypeError): + self.no_results_found() + return + + del self.search_results[:] + self.parse_releases(releases) + self.display_results() + self.fetch_coverarts() + + def fetch_coverarts(self): + if self.fetching: + return + self.fetching = True + for cell in self.cover_cells: + self.fetch_coverart(cell) + self.fetching = False + + def fetch_coverart(self, cell): + """Queue cover art jsons from CAA server for each album in search + results. + """ + if cell.fetched: + return + if not cell.is_visible(): + return + cell.fetched = True + caa_path = "/release/%s" % cell.release["musicbrainz_albumid"] + cell.fetch_task = self.tagger.webservice.download( + CAA_HOST, + CAA_PORT, + caa_path, + partial(self._caa_json_downloaded, cell) + ) + + def _caa_json_downloaded(self, cover_cell, data, http, error): + """Handle json reply from CAA server. + If server replies without error, try to get small thumbnail of front + coverart of the release. + """ + if not self.table: + return + + cover_cell.fetch_task = None + + if error: + cover_cell.not_found() + return + + try: + caa_data = load_json(data) + except ValueError: + cover_cell.not_found() + return + + front = None + for image in caa_data["images"]: + if image["front"]: + front = image + break + + if front: + url = front["thumbnails"]["small"] + coverartimage = CaaThumbnailCoverArtImage(url=url) + cover_cell.fetch_task = self.tagger.webservice.download( + coverartimage.host, + coverartimage.port, + coverartimage.path, + partial(self._cover_downloaded, cover_cell), + ) + else: + cover_cell.not_found() + + def _cover_downloaded(self, cover_cell, data, http, error): + """Handle cover art query reply from CAA server. + If server returns the cover image successfully, update the cover art + cell of particular release. + + Args: + row -- Album's row in results table + """ + if not self.table: + return + + cover_cell.fetch_task = None + + if error: + cover_cell.not_found() + else: + pixmap = QtGui.QPixmap() + try: + pixmap.loadFromData(data) + cover_cell.set_pixmap(pixmap) + except Exception as e: + cover_cell.not_found() + log.error(e) + + def fetch_cleanup(self): + for cell in self.cover_cells: + if cell.fetch_task is not None: + log.debug("Removing cover art fetch task for %s", + cell.release['musicbrainz_albumid']) + self.tagger.webservice.remove_task(cell.fetch_task) + + def closeEvent(self, event): + if self.cover_cells: + self.fetch_cleanup() + super().closeEvent(event) + + def parse_releases(self, releases): + for node in releases: + release = Metadata() + release_to_metadata(node, release) + release['score'] = node['score'] + rg_node = node['release-group'] + release_group_to_metadata(rg_node, release) + if "media" in node: + media = node['media'] + release["format"] = media_formats_from_node(media) + release["tracks"] = node['track-count'] + countries = country_list_from_node(node) + if countries: + release["country"] = ", ".join(countries) + self.search_results.append(release) + + def display_results(self): + self.prepare_table() + self.cover_cells = [] + for row, release in enumerate(self.search_results): + self.table.insertRow(row) + self.set_table_item(row, 'name', release, "album") + self.set_table_item(row, 'artist', release, "albumartist") + self.set_table_item(row, 'format', release, "format") + self.set_table_item(row, 'tracks', release, "tracks") + self.set_table_item(row, 'date', release, "date") + self.set_table_item(row, 'country', release, "country") + self.set_table_item(row, 'labels', release, "label") + self.set_table_item(row, 'catnums', release, "catalognumber") + self.set_table_item(row, 'barcode', release, "barcode") + self.set_table_item(row, 'language', release, "~releaselanguage") + self.set_table_item(row, 'type', release, "releasetype") + self.set_table_item(row, 'status', release, "releasestatus") + self.set_table_item(row, 'score', release, "score", conv=int) + self.cover_cells.append(CoverCell(self, release, row, 'cover', + on_show=self.fetch_coverart)) + self.show_table(sort_column='score') + + def accept_event(self, arg): + self.load_selection(arg) + + def load_selection(self, row): + release = self.search_results[row] + self.tagger.get_release_group_by_id( + release["musicbrainz_releasegroupid"]).loaded_albums.add( + release["musicbrainz_albumid"]) + album = self.tagger.load_album(release["musicbrainz_albumid"]) + if self.cluster: + files = self.tagger.get_files_from_objects([self.cluster]) + self.tagger.move_files_to_album(files, release["musicbrainz_albumid"], + album) diff --git a/picard/ui/searchdialog/artist.py b/picard/ui/searchdialog/artist.py new file mode 100644 index 000000000..a3f9e7813 --- /dev/null +++ b/picard/ui/searchdialog/artist.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# Copyright (C) 2016 Rahul Raturi +# Copyright (C) 2018 Laurent Monin +# +# 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 PyQt5 import QtCore +from picard import config +from picard.mbjson import artist_to_metadata +from picard.metadata import Metadata +from picard.const import QUERY_LIMIT +from picard.ui.searchdialog import SearchDialog, Retry + + +class ArtistSearchDialog(SearchDialog): + + dialog_window_size = "artistsearchdialog_window_size" + dialog_header_state = "artistsearchdialog_header_state" + + options = [ + config.Option("persist", dialog_window_size, QtCore.QSize(720, 360)), + config.Option("persist", dialog_header_state, QtCore.QByteArray()) + ] + + def __init__(self, parent): + super().__init__( + parent, + accept_button_title=_("Show in browser")) + self.setWindowTitle(_("Artist Search Dialog")) + self.columns = [ + ('name', _("Name")), + ('type', _("Type")), + ('gender', _("Gender")), + ('area', _("Area")), + ('begindate', _("Begin")), + ('beginarea', _("Begin Area")), + ('enddate', _("End")), + ('endarea', _("End Area")), + ('score', _("Score")), + ] + + def search(self, text): + self.retry_params = Retry(self.search, text) + self.search_box_text(text) + self.show_progress() + self.tagger.mb_api.find_artists(self.handle_reply, + query=text, + search=True, + limit=QUERY_LIMIT) + + def retry(self): + self.retry_params.function(self.retry_params.query) + + def handle_reply(self, document, http, error): + if error: + self.network_error(http, error) + return + + try: + artists = document['artists'] + except (KeyError, TypeError): + self.no_results() + return + + del self.search_results[:] + self.parse_artists(artists) + self.display_results() + + def parse_artists(self, artists): + for node in artists: + artist = Metadata() + artist_to_metadata(node, artist) + artist['score'] = node['score'] + self.search_results.append(artist) + + def display_results(self): + self.prepare_table() + for row, artist in enumerate(self.search_results): + self.table.insertRow(row) + self.set_table_item(row, 'name', artist, "name") + self.set_table_item(row, 'type', artist, "type") + self.set_table_item(row, 'gender', artist, "gender") + self.set_table_item(row, 'area', artist, "area") + self.set_table_item(row, 'begindate', artist, "begindate") + self.set_table_item(row, 'beginarea', artist, "beginarea") + self.set_table_item(row, 'enddate', artist, "enddate") + self.set_table_item(row, 'endarea', artist, "endarea") + self.set_table_item(row, 'score', artist, "score", conv=int) + self.show_table(sort_column='score') + + def accept_event(self, row): + self.load_in_browser(row) + + def load_in_browser(self, row): + self.tagger.search(self.search_results[row]["musicbrainz_artistid"], "artist") diff --git a/picard/ui/searchdialog/track.py b/picard/ui/searchdialog/track.py new file mode 100644 index 000000000..7013efebf --- /dev/null +++ b/picard/ui/searchdialog/track.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# Copyright (C) 2016 Rahul Raturi +# Copyright (C) 2018 Laurent Monin +# +# 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 PyQt5 import QtCore +from operator import itemgetter +from picard import config +from picard.file import File +from picard.mbjson import ( + recording_to_metadata, + release_to_metadata, + release_group_to_metadata, + country_list_from_node +) +from picard.metadata import Metadata +from picard.webservice.api_helpers import escape_lucene_query +from picard.track import Track +from picard.const import QUERY_LIMIT +from picard.ui.searchdialog import SearchDialog, Retry + + +class TrackSearchDialog(SearchDialog): + + dialog_window_size = "tracksearchdialog_window_size" + dialog_header_state = "tracksearchdialog_header_state" + + options = [ + config.Option("persist", dialog_window_size, QtCore.QSize(720, 360)), + config.Option("persist", dialog_header_state, QtCore.QByteArray()) + ] + + def __init__(self, parent): + super().__init__( + parent, + accept_button_title=_("Load into Picard")) + self.file_ = None + self.setWindowTitle(_("Track Search Results")) + self.columns = [ + ('name', _("Name")), + ('length', _("Length")), + ('artist', _("Artist")), + ('release', _("Release")), + ('date', _("Date")), + ('country', _("Country")), + ('type', _("Type")), + ('score', _("Score")), + ] + + def search(self, text): + """Perform search using query provided by the user.""" + self.retry_params = Retry(self.search, text) + self.search_box_text(text) + self.show_progress() + self.tagger.mb_api.find_tracks(self.handle_reply, + query=text, + search=True, + limit=QUERY_LIMIT) + + def load_similar_tracks(self, file_): + """Perform search using existing metadata information + from the file as query.""" + self.retry_params = Retry(self.load_similar_tracks, file_) + self.file_ = file_ + metadata = file_.orig_metadata + query = { + 'track': metadata['title'], + 'artist': metadata['artist'], + 'release': metadata['album'], + 'tnum': metadata['tracknumber'], + 'tracks': metadata['totaltracks'], + 'qdur': string_(metadata.length // 2000), + 'isrc': metadata['isrc'], + } + + # Generate query to be displayed to the user (in search box). + # If advanced query syntax setting is enabled by user, display query in + # advanced syntax style. Otherwise display only track title. + if config.setting["use_adv_search_syntax"]: + query_str = ' '.join(['%s:(%s)' % (item, escape_lucene_query(value)) + for item, value in query.items() if value]) + else: + query_str = query["track"] + + query["limit"] = QUERY_LIMIT + self.search_box_text(query_str) + self.show_progress() + self.tagger.mb_api.find_tracks( + self.handle_reply, + **query) + + def retry(self): + self.retry_params.function(self.retry_params.query) + + def handle_reply(self, document, http, error): + if error: + self.network_error(http, error) + return + + try: + tracks = document['recordings'] + except (KeyError, TypeError): + self.no_results_found() + return + + if self.file_: + sorted_results = sorted( + (self.file_.orig_metadata.compare_to_track( + track, + File.comparison_weights) + for track in tracks), + reverse=True, + key=itemgetter(0)) + tracks = [item[3] for item in sorted_results] + + del self.search_results[:] # Clear existing data + self.parse_tracks(tracks) + self.display_results() + + def display_results(self): + self.prepare_table() + for row, obj in enumerate(self.search_results): + track = obj[0] + self.table.insertRow(row) + self.set_table_item(row, 'name', track, "title") + self.set_table_item(row, 'length', track, "~length") + self.set_table_item(row, 'artist', track, "artist") + self.set_table_item(row, 'release', track, "album") + self.set_table_item(row, 'date', track, "date") + self.set_table_item(row, 'country', track, "country") + self.set_table_item(row, 'type', track, "releasetype") + self.set_table_item(row, 'score', track, "score", conv=int) + self.show_table(sort_column='score') + + def parse_tracks(self, tracks): + for node in tracks: + if "releases" in node: + for rel_node in node['releases']: + track = Metadata() + recording_to_metadata(node, track) + track['score'] = node['score'] + release_to_metadata(rel_node, track) + rg_node = rel_node['release-group'] + release_group_to_metadata(rg_node, track) + countries = country_list_from_node(rel_node) + if countries: + track["country"] = ", ".join(countries) + self.search_results.append((track, node)) + else: + # This handles the case when no release is associated with a track + # i.e. the track is an NAT + track = Metadata() + recording_to_metadata(node, track) + track['score'] = node['score'] + track["album"] = _("Standalone Recording") + self.search_results.append((track, node)) + + def accept_event(self, arg): + self.load_selection(arg) + + def load_selection(self, row): + """Load the album corresponding to the selected track. + If the search is performed for a file, also associate the file to + corresponding track in the album. + """ + + track, node = self.search_results[row] + if track.get("musicbrainz_albumid"): + # The track is not an NAT + self.tagger.get_release_group_by_id(track["musicbrainz_releasegroupid"]).loaded_albums.add( + track["musicbrainz_albumid"]) + if self.file_: + # Search is performed for a file. + # Have to move that file from its existing album to the new one. + if isinstance(self.file_.parent, Track): + album = self.file_.parent.album + self.tagger.move_file_to_track(self.file_, track["musicbrainz_albumid"], track["musicbrainz_recordingid"]) + if album._files == 0: + # Remove album if it has no more files associated + self.tagger.remove_album(album) + else: + self.tagger.move_file_to_track(self.file_, track["musicbrainz_albumid"], track["musicbrainz_recordingid"]) + else: + # No files associated. Just a normal search. + self.tagger.load_album(track["musicbrainz_albumid"]) + else: + if self.file_: + album = self.file_.parent.album + self.tagger.move_file_to_nat(track["musicbrainz_recordingid"]) + if album._files == 0: + self.tagger.remove_album(album) + else: + self.tagger.load_nat(track["musicbrainz_recordingid"], node)