1# -*- coding: utf-8 -*- 2# 3# Picard, the next-generation MusicBrainz tagger 4# 5# Copyright (C) 2016 Rahul Raturi 6# Copyright (C) 2018-2019 Laurent Monin 7# Copyright (C) 2018-2020 Philipp Wolfer 8# 9# This program is free software; you can redistribute it and/or 10# modify it under the terms of the GNU General Public License 11# as published by the Free Software Foundation; either version 2 12# of the License, or (at your option) any later version. 13# 14# This program is distributed in the hope that it will be useful, 15# but WITHOUT ANY WARRANTY; without even the implied warranty of 16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17# GNU General Public License for more details. 18# 19# You should have received a copy of the GNU General Public License 20# along with this program; if not, write to the Free Software 21# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 22 23 24from functools import partial 25 26from PyQt5 import ( 27 QtCore, 28 QtGui, 29 QtWidgets, 30) 31from PyQt5.QtCore import pyqtSignal 32 33from picard import log 34from picard.config import Option 35from picard.const import ( 36 CAA_HOST, 37 CAA_PORT, 38 QUERY_LIMIT, 39) 40from picard.coverart.image import CaaThumbnailCoverArtImage 41from picard.mbjson import ( 42 countries_from_node, 43 media_formats_from_node, 44 release_group_to_metadata, 45 release_to_metadata, 46) 47from picard.metadata import Metadata 48from picard.webservice.api_helpers import escape_lucene_query 49 50from picard.ui.searchdialog import ( 51 Retry, 52 SearchDialog, 53) 54 55 56class CoverWidget(QtWidgets.QWidget): 57 58 shown = pyqtSignal() 59 60 def __init__(self, parent, width=100, height=100): 61 super().__init__(parent) 62 self.layout = QtWidgets.QVBoxLayout(self) 63 self.layout.setContentsMargins(0, 0, 0, 0) 64 self.layout.setAlignment(QtCore.Qt.AlignCenter) 65 self.loading_gif_label = QtWidgets.QLabel(self) 66 self.loading_gif_label.setAlignment(QtCore.Qt.AlignCenter) 67 loading_gif = QtGui.QMovie(":/images/loader.gif") 68 self.loading_gif_label.setMovie(loading_gif) 69 loading_gif.start() 70 self.layout.addWidget(self.loading_gif_label) 71 self.__sizehint = self.__size = QtCore.QSize(width, height) 72 self.setStyleSheet("padding: 0") 73 74 def set_pixmap(self, pixmap): 75 wid = self.layout.takeAt(0) 76 if wid: 77 wid.widget().deleteLater() 78 cover_label = QtWidgets.QLabel(self) 79 pixmap = pixmap.scaled(self.__size, QtCore.Qt.KeepAspectRatio, 80 QtCore.Qt.SmoothTransformation) 81 self.__sizehint = pixmap.size() 82 cover_label.setPixmap(pixmap) 83 self.layout.addWidget(cover_label) 84 85 def not_found(self): 86 """Update the widget with a blank image.""" 87 shadow = QtGui.QPixmap(":/images/CoverArtShadow.png") 88 self.set_pixmap(shadow) 89 90 def sizeHint(self): 91 return self.__sizehint 92 93 def showEvent(self, event): 94 super().showEvent(event) 95 self.shown.emit() 96 97 98class CoverCell: 99 100 def __init__(self, parent, release, row, colname, on_show=None): 101 self.parent = parent 102 self.release = release 103 self.fetched = False 104 self.fetch_task = None 105 self.row = row 106 self.column = self.parent.colpos(colname) 107 widget = CoverWidget(self.parent.table) 108 if on_show is not None: 109 widget.shown.connect(partial(on_show, self)) 110 self.parent.table.setCellWidget(row, self.column, widget) 111 112 def widget(self): 113 if not self.parent.table: 114 return None 115 return self.parent.table.cellWidget(self.row, self.column) 116 117 def is_visible(self): 118 widget = self.widget() 119 if not widget: 120 return False 121 return not widget.visibleRegion().isEmpty() 122 123 def set_pixmap(self, pixmap): 124 widget = self.widget() 125 if widget: 126 widget.set_pixmap(pixmap) 127 128 def not_found(self): 129 widget = self.widget() 130 if widget: 131 widget.not_found() 132 133 134class AlbumSearchDialog(SearchDialog): 135 136 dialog_header_state = "albumsearchdialog_header_state" 137 138 options = [ 139 Option("persist", dialog_header_state, QtCore.QByteArray()) 140 ] 141 142 def __init__(self, parent, force_advanced_search=None): 143 super().__init__( 144 parent, 145 accept_button_title=_("Load into Picard"), 146 search_type="album", 147 force_advanced_search=force_advanced_search) 148 self.cluster = None 149 self.setWindowTitle(_("Album Search Results")) 150 self.columns = [ 151 ('name', _("Name")), 152 ('artist', _("Artist")), 153 ('format', _("Format")), 154 ('tracks', _("Tracks")), 155 ('date', _("Date")), 156 ('country', _("Country")), 157 ('labels', _("Labels")), 158 ('catnums', _("Catalog #s")), 159 ('barcode', _("Barcode")), 160 ('language', _("Language")), 161 ('type', _("Type")), 162 ('status', _("Status")), 163 ('cover', _("Cover")), 164 ('score', _("Score")), 165 ] 166 self.cover_cells = [] 167 self.fetching = False 168 self.scrolled.connect(self.fetch_coverarts) 169 170 def search(self, text): 171 """Perform search using query provided by the user.""" 172 self.retry_params = Retry(self.search, text) 173 self.search_box_text(text) 174 self.show_progress() 175 self.tagger.mb_api.find_releases(self.handle_reply, 176 query=text, 177 search=True, 178 advanced_search=self.use_advanced_search, 179 limit=QUERY_LIMIT) 180 181 def show_similar_albums(self, cluster): 182 """Perform search by using existing metadata information 183 from the cluster as query.""" 184 self.retry_params = Retry(self.show_similar_albums, cluster) 185 self.cluster = cluster 186 metadata = cluster.metadata 187 query = { 188 "artist": metadata["albumartist"], 189 "release": metadata["album"], 190 "tracks": str(len(cluster.files)) 191 } 192 193 # Generate query to be displayed to the user (in search box). 194 # If advanced query syntax setting is enabled by user, display query in 195 # advanced syntax style. Otherwise display only album title. 196 if self.use_advanced_search: 197 query_str = ' '.join(['%s:(%s)' % (item, escape_lucene_query(value)) 198 for item, value in query.items() if value]) 199 else: 200 query_str = query["release"] 201 202 query["limit"] = QUERY_LIMIT 203 self.search_box_text(query_str) 204 self.show_progress() 205 self.tagger.mb_api.find_releases( 206 self.handle_reply, 207 **query) 208 209 def retry(self): 210 self.retry_params.function(self.retry_params.query) 211 212 def handle_reply(self, document, http, error): 213 if error: 214 self.network_error(http, error) 215 return 216 217 try: 218 releases = document['releases'] 219 except (KeyError, TypeError): 220 self.no_results_found() 221 return 222 223 del self.search_results[:] 224 self.parse_releases(releases) 225 self.display_results() 226 self.fetch_coverarts() 227 228 def fetch_coverarts(self): 229 if self.fetching: 230 return 231 self.fetching = True 232 for cell in self.cover_cells: 233 self.fetch_coverart(cell) 234 self.fetching = False 235 236 def fetch_coverart(self, cell): 237 """Queue cover art jsons from CAA server for each album in search 238 results. 239 """ 240 if cell.fetched: 241 return 242 if not cell.is_visible(): 243 return 244 cell.fetched = True 245 caa_path = "/release/%s" % cell.release["musicbrainz_albumid"] 246 cell.fetch_task = self.tagger.webservice.get( 247 CAA_HOST, 248 CAA_PORT, 249 caa_path, 250 partial(self._caa_json_downloaded, cell) 251 ) 252 253 def _caa_json_downloaded(self, cover_cell, data, http, error): 254 """Handle json reply from CAA server. 255 If server replies without error, try to get small thumbnail of front 256 coverart of the release. 257 """ 258 cover_cell.fetch_task = None 259 260 if error: 261 cover_cell.not_found() 262 return 263 264 front = None 265 try: 266 for image in data["images"]: 267 if image["front"]: 268 front = image 269 break 270 271 if front: 272 url = front["thumbnails"]["small"] 273 coverartimage = CaaThumbnailCoverArtImage(url=url) 274 cover_cell.fetch_task = self.tagger.webservice.download( 275 coverartimage.host, 276 coverartimage.port, 277 coverartimage.path, 278 partial(self._cover_downloaded, cover_cell) 279 ) 280 else: 281 cover_cell.not_found() 282 except (AttributeError, KeyError, TypeError): 283 log.error("Error reading CAA response", exc_info=True) 284 cover_cell.not_found() 285 286 def _cover_downloaded(self, cover_cell, data, http, error): 287 """Handle cover art query reply from CAA server. 288 If server returns the cover image successfully, update the cover art 289 cell of particular release. 290 291 Args: 292 row -- Album's row in results table 293 """ 294 cover_cell.fetch_task = None 295 296 if error: 297 cover_cell.not_found() 298 else: 299 pixmap = QtGui.QPixmap() 300 try: 301 pixmap.loadFromData(data) 302 cover_cell.set_pixmap(pixmap) 303 except Exception as e: 304 cover_cell.not_found() 305 log.error(e) 306 307 def fetch_cleanup(self): 308 for cell in self.cover_cells: 309 if cell.fetch_task is not None: 310 log.debug("Removing cover art fetch task for %s", 311 cell.release['musicbrainz_albumid']) 312 self.tagger.webservice.remove_task(cell.fetch_task) 313 314 def closeEvent(self, event): 315 if self.cover_cells: 316 self.fetch_cleanup() 317 super().closeEvent(event) 318 319 def parse_releases(self, releases): 320 for node in releases: 321 release = Metadata() 322 release_to_metadata(node, release) 323 release['score'] = node['score'] 324 rg_node = node['release-group'] 325 release_group_to_metadata(rg_node, release) 326 if "media" in node: 327 media = node['media'] 328 release["format"] = media_formats_from_node(media) 329 release["tracks"] = node['track-count'] 330 countries = countries_from_node(node) 331 if countries: 332 release["country"] = ", ".join(countries) 333 self.search_results.append(release) 334 335 def display_results(self): 336 self.prepare_table() 337 self.cover_cells = [] 338 for row, release in enumerate(self.search_results): 339 self.table.insertRow(row) 340 self.set_table_item(row, 'name', release, "album") 341 self.set_table_item(row, 'artist', release, "albumartist") 342 self.set_table_item(row, 'format', release, "format") 343 self.set_table_item(row, 'tracks', release, "tracks") 344 self.set_table_item(row, 'date', release, "date") 345 self.set_table_item(row, 'country', release, "country") 346 self.set_table_item(row, 'labels', release, "label") 347 self.set_table_item(row, 'catnums', release, "catalognumber") 348 self.set_table_item(row, 'barcode', release, "barcode") 349 self.set_table_item(row, 'language', release, "~releaselanguage") 350 self.set_table_item(row, 'type', release, "releasetype") 351 self.set_table_item(row, 'status', release, "releasestatus") 352 self.set_table_item(row, 'score', release, "score") 353 self.cover_cells.append(CoverCell(self, release, row, 'cover', 354 on_show=self.fetch_coverart)) 355 self.show_table(sort_column='score') 356 357 def accept_event(self, rows): 358 for row in rows: 359 self.load_selection(row) 360 361 def load_selection(self, row): 362 release = self.search_results[row] 363 self.tagger.get_release_group_by_id( 364 release["musicbrainz_releasegroupid"]).loaded_albums.add( 365 release["musicbrainz_albumid"]) 366 album = self.tagger.load_album(release["musicbrainz_albumid"]) 367 if self.cluster: 368 files = self.cluster.iterfiles() 369 self.tagger.move_files_to_album(files, release["musicbrainz_albumid"], 370 album) 371