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