summaryrefslogtreecommitdiff
path: root/nephilim/plugins/AlbumCover.py
blob: ea34b4b539610a4b6f872a4ce05d323823c0c9c0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
#
#    Copyright (C) 2009 Anton Khirnov <wyskas@gmail.com>
#
#    Nephilim 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 3 of the License, or
#    (at your option) any later version.
#
#    Nephilim 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 Nephilim.  If not, see <http://www.gnu.org/licenses/>.
#

from PyQt5        import QtGui, QtWidgets, QtCore, QtNetwork
from PyQt5.QtCore import pyqtSignal as Signal

import os

from ..plugin import Plugin
from ..       import common, metadata_fetcher, song
from ..       import icons

class AlbumCoverWidget(QtWidgets.QLabel):
    "cover - QPixmap or None"
    cover           = None
    "is there a (non-default) cover loaded?"
    cover_loaded    = False
    "plugin object"
    plugin          = None
    "logger"
    logger          = None

    _menu           = None   # popup menu

    def __init__(self, plugin):
        QtWidgets.QLabel.__init__(self)
        self.plugin = plugin
        self.logger = plugin.logger
        self.setAlignment(QtCore.Qt.AlignCenter)

        # popup menu
        self._menu = QtWidgets.QMenu('album')
        self._menu.addAction('&Select cover file...', self.plugin.select_cover)
        self._menu.addAction('&Refresh cover.', self.plugin.refresh)
        self._menu.addAction('&View in a separate window.', self.__view_cover)
        self._menu.addAction('Save cover &as...', self.__save_cover)
        self._menu.addAction('&Clear cover.', self.__clear_cover)

    def contextMenuEvent(self, event):
        event.accept()
        self._menu.popup(event.globalPos())

    def set_cover(self, song, cover):
        """Set cover for current song."""
        self.logger.info('Setting cover')
        if not cover or cover.isNull():
            self.cover        = None
            self.cover_loaded = False
            self.setPixmap(QtGui.QPixmap(':icons/nephilim.png'))
            self.plugin.cover_changed.emit(QtGui.QPixmap())
            return

        if song != self.plugin.mpclient.cur_song:
            return

        self.cover = cover
        self.cover_loaded = True
        self.setPixmap(self.cover.scaled(self.size(), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation))
        self.plugin.cover_changed.emit(self.cover)
        self.logger.info('Cover set.')

    def __view_cover(self):
        if not self.cover_loaded:
            return
        win = QtWidgets.QLabel(self, QtCore.Qt.Window)
        win.setScaledContents(True)
        win.setPixmap(self.cover)
        win.show()

    def __save_cover(self):
        if not self.cover_loaded:
            return
        cover = self.cover
        file = QtWidgets.QFileDialog.getSaveFileName(None, '', QtCore.QDir.homePath())
        if file:
            self.plugin.save_cover_file(cover, file)

    def __clear_cover(self):
        self.plugin.delete_cover_file()
        self.set_cover(None, None)
        self.plugin.refresh()

class AlbumCover(Plugin):
    # public, constant
    info = 'Display the album cover of the currently playing album.'

    # public, read-only
    o = None

    # private
    DEFAULTS  = {'coverdir' : '${musicdir}/${songdir}', 'covername' : '.cover_%s_${artist}_${album}'%common.APPNAME,
                 'fetchers': ['local', 'Last.fm'], 'store' : 1}
    "implemented fetchers"
    available_fetchers = None
    "enabled fetchers, those with higher priority first"
    __fetchers        = None
    "number of returned results from last refresh() call"
    __results         = None
    "index/priority of current cover"
    __index           = None
    "metadata paths"
    __cover_dir      = None
    __cover_path     = None

    # SIGNALS
    cover_changed = Signal(QtGui.QPixmap)

    #### private ####
    def __init__(self, parent, mpclient, name):
        Plugin.__init__(self, parent, mpclient, name)

        self.__fetchers         = []
        self.available_fetchers = [FetcherLocal, FetcherLastfm]

    def __new_cover_fetched(self, song, cover):
        self.logger.info('Got new cover.')
        self.__results += 1

        i = self.__fetchers.index(self.sender())
        if cover and i < self.__index:
            if int(self.settings.value(self.name + '/store')):
                self.save_cover_file(cover)
            self.__index = i
            return self.o.set_cover(song, cover)
        elif self.__results >= len(self.__fetchers) and not self.o.cover_loaded:
            self.o.set_cover(song, None)

    def __abort_fetch(self):
        """Aborts all fetches currently in progress."""
        for fetcher in self.__fetchers:
            fetcher.abort()

    class SettingsWidgetAlbumCover(Plugin.SettingsWidget):
        coverdir    = None
        covername   = None
        store       = None
        fetcherlist = None

        def __init__(self, plugin):
            Plugin.SettingsWidget.__init__(self, plugin)
            self.settings.beginGroup(self.plugin.name)

            # store covers groupbox
            self.store = QtWidgets.QGroupBox('Store covers.')
            self.store.setToolTip('Should %s store its own copy of covers?'%common.APPNAME)
            self.store.setCheckable(True)
            self.store.setChecked(int(self.settings.value('store')))
            self.store.setLayout(QtWidgets.QGridLayout())

            # paths to covers
            self.coverdir  = QtWidgets.QLineEdit(self.settings.value('coverdir'))
            self.coverdir.setToolTip('Where should %s store covers.\n'
                                     '${musicdir} will be expanded to path to MPD music library (as set by user)\n'
                                     '${songdir} will be expanded to path to the song (relative to ${musicdir}\n'
                                     'other tags same as in covername'
                                      %common.APPNAME)
            self.covername = QtWidgets.QLineEdit(self.settings.value('covername'))
            self.covername.setToolTip('Filename for %s cover files.\n'
                                      'All tags supported by MPD will be expanded to their\n'
                                      'values for current song, e.g. ${title}, ${track}, ${artist},\n'
                                      '${album}, ${genre} etc.'%common.APPNAME)
            self.store.layout().addWidget(QtWidgets.QLabel('Cover directory'), 0, 0)
            self.store.layout().addWidget(self.coverdir, 0, 1)
            self.store.layout().addWidget(QtWidgets.QLabel('Cover filename'), 1, 0)
            self.store.layout().addWidget(self.covername, 1, 1)

            # sites list
            fetchers         = self.settings.value('fetchers')
            self.fetcherlist = QtWidgets.QListWidget(self)
            self.fetcherlist.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
            for site in fetchers:
                it = QtWidgets.QListWidgetItem(site)
                it.setCheckState(QtCore.Qt.Checked)
                self.fetcherlist.addItem(it)
            for site in self.plugin.available_fetchers:
                if not site.name in fetchers:
                    it = QtWidgets.QListWidgetItem(site.name)
                    it.setCheckState(QtCore.Qt.Unchecked)
                    self.fetcherlist.addItem(it)

            self.setLayout(QtWidgets.QVBoxLayout())
            self.layout().addWidget(self.store)
            self._add_widget(self.fetcherlist, label = 'Fetchers', tooltip = 'A list of sources used for fetching covers.\n'
                                                                    'Use drag and drop to change their priority.')

            self.settings.endGroup()

        def save_settings(self):
            self.settings.beginGroup(self.plugin.name)
            self.settings.setValue('coverdir',  self.coverdir.text())
            self.settings.setValue('covername', self.covername.text())
            self.settings.setValue('store',     int(self.store.isChecked()))

            fetchers = []
            for i in range(self.fetcherlist.count()):
                it = self.fetcherlist.item(i)
                if it.checkState() == QtCore.Qt.Checked:
                    fetchers.append(it.text())
            self.settings.setValue('fetchers', fetchers)
            self.settings.endGroup()
            self.plugin.refresh_fetchers()
            self.plugin.refresh()

    #### public ####
    def _load(self):
        self.o = AlbumCoverWidget(self)
        self.mpclient.song_changed.connect(self.refresh)
        self.refresh_fetchers()
        self.refresh()
    def _unload(self):
        self.o = None
        self.mpclient.song_changed.disconnect(self.refresh)

    def refresh(self):
        self.logger.info('Autorefreshing cover.')
        self.__results = 0
        self.__index   = len(self.__fetchers)
        self.o.cover_loaded = False
        song = self.mpclient.cur_song
        if not song:
            self.__cover_dir  = ''
            self.__cover_path = ''
            return self.o.set_cover(None, None)

        (self.__cover_dir, self.__cover_path) = common.generate_metadata_path(song,
                                                                            self.settings.value(self.name + '/coverdir'),
                                                                            self.settings.value(self.name + '/covername'))
        try:
            self.logger.info('Trying to read cover from file %s.'%self.__cover_path)
            cover = QtGui.QPixmap(self.__cover_path)
            if not cover.isNull():
                return self.o.set_cover(song, cover)
        except IOError, e:
            self.logger.info('Error reading cover file: %s.'%e)

        for fetcher in self.__fetchers:
            fetcher.fetch(song)

    def refresh_fetchers(self):
        """Refresh the list of available fetchers."""
        self.__fetchers = []
        # append fetchers in order they are stored in settings
        for name in self.settings.value('%s/fetchers'%self.name):
            for site in self.available_fetchers:
                if site.name == name:
                    self.__fetchers.append(site(self))
                    self.__fetchers[-1].finished.connect(self.__new_cover_fetched)

    def save_cover_file(self, cover, path = None):
        """Save cover to a file specified in path.
           If path is None, then a default value is used."""
        self.logger.info('Saving cover...')
        try:
            if not path:
                path = self.__cover_path
            cover.save(path, 'png')
            self.logger.info('Cover successfully saved.')
        except IOError, e:
            self.logger.error('Error writing cover: %s', e)

    def delete_cover_file(self, song = None):
        """Delete a cover file for song. If song is not specified
           current song is used."""
        if not song:
            path = self.__cover_path
        else:
            path = common.generate_metadata_path(song, self.settings.value(self.name + '/coverdir'),
                                               self.settings.value(self.name + '/covername'))
        if not QtCore.QFile.remove(path):
            self.logger.error('Error removing file %s.'%path)

    def select_cover(self):
        """Prompt user to manually select cover file for current song."""
        song = self.mpclient.cur_song
        if not song:
            return
        self.__abort_fetch()

        file = QtWidgets.QFileDialog.getOpenFileName(None,
                'Select album cover for %s - %s'%(song['?artist'], song['?album']),
                self.__cover_dir, '')
        if not file:
            return

        cover = QtGui.QPixmap(file)
        if cover.isNull():
            self.logger.error('Error opening cover file.')
            return

        if int(self.settings.value(self.name + '/store')):
            self.save_cover_file(cover)
        self.o.set_cover(song, cover)

    def cover(self):
        if not self.o:
            return None
        return self.o.cover if self.o.cover_loaded else None

    def _get_dock_widget(self):
        return self._create_dock(self.o)

    def get_settings_widget(self):
        return self.SettingsWidgetAlbumCover(self)

class FetcherLastfm(metadata_fetcher.MetadataFetcher):
    name = 'Last.fm'

    def fetch(self, song):
        self.song = song
        if not 'artist' in song or not 'album' in song:
            return self.finish()

        query = QtCore.QUrlQuery()
        query.setQueryItems([('api_key', 'beedb2a8a0178b8059cd6c7e57fbe428'),
                             ('method',  'album.getInfo'),
                             ('artist',  song['artist']),
                             ('album',   song['album']),
                             ('mbid',    song['?MUSICBRAINZ_ALBUMID'])])
        url = QtCore.QUrl('http://ws.audioscrobbler.com/2.0/')
        url.setQuery(query)

        self.fetch2(song, url)
        self.rep.finished.connect(self.__handle_search_res)

    def __handle_search_res(self):
        url = None
        xml = QtCore.QXmlStreamReader(self.rep)

        while not xml.atEnd():
            token = xml.readNext()
            if token == QtCore.QXmlStreamReader.StartElement:
                if xml.name() == 'image' and xml.attributes().value('size') == 'extralarge':
                    url = QtCore.QUrl() # the url is already percent-encoded
                    try:
                        url.setUrl(xml.readElementText())
                    except TypeError:   #no text
                        url = None
        if xml.hasError():
            self.logger.error('Error parsing seach results: %s'%xml.errorString())

        if not url:
            self.logger.info('Didn\'t find the URL in %s search results.'%self.name)
            return self.finish()
        self.logger.info('Found %s song URL: %s.'%(self.name, url))

        self.rep = self.nam.get(QtNetwork.QNetworkRequest(url))
        self.rep.finished.connect(self.__handle_cover)

    def __handle_cover(self):
        data = self.rep.readAll()
        pixmap = QtGui.QPixmap()
        if pixmap.loadFromData(data):
            self.finish(pixmap)
        else:
            self.finish()

class FetcherLocal(QtCore.QObject):
    """This fetcher tries to find cover files in the same directory as
       current song."""
    #public, read-only
    name     = 'local'
    logger   = None
    settings = None

    # SIGNALS
    finished = Signal([song.Song, object])

    def __init__(self, plugin):
        QtCore.QObject.__init__(self, plugin)
        self.logger = plugin.logger
        self.settings = QtCore.QSettings()

    def fetch(self, song):
        self.logger.info('Trying to guess local cover name.')
        # guess cover name
        covers = ['cover', 'album', 'front']

        exts = []
        for ext in QtGui.QImageReader().supportedImageFormats():
            exts.append('*.%s'%str(ext))

        filter = []
        for cover in covers:
            for ext in exts:
                filter.append('*.%s%s'%(cover,ext))

        dirname, filename = common.generate_metadata_path(song, '$musicdir/$songdir', '')
        dir = QtCore.QDir(dirname)
        if not dir:
            self.logger.error('Error opening directory %s.'%dirname)
            return self.finished.emit(song, None)

        dir.setNameFilters(filter)
        files = dir.entryList()
        if files:
            cover = QtGui.QPixmap(dir.filePath(files[0]))
            if not cover.isNull():
                self.logger.info('Found a cover: %s'%dir.filePath(files[0]))
                return self.finished.emit(song, cover)

        # if this failed, try any supported image
        dir.setNameFilters(exts)
        files = dir.entryList()
        if files:
            cover = QtGui.QPixmap(dir.filePath(files[0]))
            if not cover.isNull():
                self.logger.info('Found a cover: %s'%dir.filePath(files[0]))
                return self.finished.emit(song, cover)
        self.logger.info('No matching cover found')
        self.finished.emit(song, None)

    def abort(self):
        pass