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)