Merge pull request #642 from antlarr/album-cover-art

PICARD-1000: Show album/cluster cover art
This commit is contained in:
Laurent Monin
2017-03-13 13:31:24 +01:00
committed by GitHub
11 changed files with 328 additions and 42 deletions

View File

@@ -58,6 +58,7 @@ class Album(DataObject, Item):
def __init__(self, id, discid=None):
DataObject.__init__(self, id)
self.metadata = Metadata()
self.orig_metadata = Metadata()
self.tracks = []
self.loaded = False
self.load_task = None
@@ -71,6 +72,7 @@ class Album(DataObject, Item):
self.errors = []
self.status = None
self._album_artists = []
self.update_metadata_images_enabled = True
def __repr__(self):
return '<Album %s %r>' % (self.id, self.metadata[u"album"])
@@ -83,6 +85,11 @@ class Album(DataObject, Item):
for file in self.unmatched_files.iterfiles():
yield file
def enable_update_metadata_images(self, enabled):
self.update_metadata_images_enabled = enabled
if enabled:
self.update_metadata_images()
def append_album_artist(self, id):
"""Append artist id to the list of album artists
and return an AlbumArtist instance"""
@@ -254,6 +261,7 @@ class Album(DataObject, Item):
self._tracks_loaded = True
if not self._requests:
self.enable_update_metadata_images(False)
# Prepare parser for user's script
if config.setting["enable_tagger_scripts"]:
for s_pos, s_name, s_enabled, s_text in config.setting["list_of_scripts"]:
@@ -275,6 +283,7 @@ class Album(DataObject, Item):
self._new_metadata.strip_whitespace()
for track in self.tracks:
track.metadata_images_changed.connect(self.update_metadata_images)
for file in list(track.linked_files):
file.move(self.unmatched_files)
self.metadata = self._new_metadata
@@ -285,6 +294,7 @@ class Album(DataObject, Item):
self.status = None
self.match_files(self.unmatched_files.files)
self.update()
self.enable_update_metadata_images(True)
self.tagger.window.set_statusbar_message(
N_('Album %(id)s loaded: %(artist)s - %(album)s'),
{
@@ -378,14 +388,19 @@ class Album(DataObject, Item):
def update(self, update_tracks=True):
if self.item:
self.item.update(update_tracks)
self.update_metadata_images()
def _add_file(self, track, file):
self._files += 1
self.update(update_tracks=False)
file.metadata_images_changed.connect(self.update_metadata_images)
self.update_metadata_images()
def _remove_file(self, track, file):
self._files -= 1
self.update(update_tracks=False)
file.metadata_images_changed.disconnect(self.update_metadata_images)
self.update_metadata_images()
def match_files(self, files, use_recordingid=True):
"""Match files to tracks on this album, based on metadata similarity or recordingid."""
@@ -552,6 +567,52 @@ class Album(DataObject, Item):
self.tagger.albums[mbid] = self
self.load(priority=True, refresh=True)
def update_metadata_images(self):
if not self.update_metadata_images_enabled:
return
class State:
new_images = []
orig_images = []
has_common_new_images = True
has_common_orig_images = True
first_new_obj = True
first_orig_obj = True
state = State()
def process_images(state, obj):
# Check new images
if state.first_new_obj:
state.new_images = obj.metadata.images[:]
state.first_new_obj = False
else:
if state.new_images != obj.metadata.images:
state.has_common_new_images = False
state.new_images.extend([image for image in obj.metadata.images if image not in state.new_images])
if isinstance(obj, Track):
return
# Check orig images, but not for Tracks (which don't have orig_metadata)
if state.first_orig_obj:
state.orig_images = obj.orig_metadata.images[:]
state.first_orig_obj = False
else:
if state.orig_images != obj.orig_metadata.images:
state.has_common_orig_images = False
state.orig_images.extend([image for image in obj.orig_metadata.images if image not in state.orig_images])
for track in self.tracks:
process_images(state, track)
for file in list(track.linked_files):
process_images(state, file)
for file in list(self.unmatched_files.files):
process_images(state, file)
self.metadata.images = state.new_images
self.metadata.has_common_images = state.has_common_new_images
self.orig_metadata.images = state.orig_images
self.orig_metadata.has_common_images = state.has_common_orig_images
class NatAlbum(Album):

View File

@@ -69,6 +69,9 @@ class Cluster(QtCore.QObject, Item):
self.metadata.length += file.metadata.length
file._move(self)
file.update(signal=False)
cover = file.metadata.get_single_front_image()
if cover and cover[0] not in self.metadata.images:
self.metadata.append_image(cover[0])
self.files.extend(files)
self.metadata['totaltracks'] = len(self.files)
self.item.add_files(files)
@@ -79,6 +82,9 @@ class Cluster(QtCore.QObject, Item):
self.metadata['totaltracks'] = len(self.files)
file._move(self)
file.update(signal=False)
cover = file.metadata.get_single_front_image()
if cover and cover[0] not in self.metadata.images:
self.metadata.append_image(cover[0])
self.item.add_file(file)
def remove_file(self, file):

View File

@@ -113,3 +113,6 @@ PLUGINS_API = {
# Default query limit
QUERY_LIMIT = 25
# Maximum number of covers to draw in a stack in CoverArtThumbnail
MAX_COVERS_TO_STACK = 4

View File

@@ -70,6 +70,9 @@ class DataHash:
def __eq__(self, other):
return self._hash == other._hash
def hash(self):
return self._hash
def delete_file(self):
if self._filename:
try:
@@ -211,6 +214,11 @@ class CoverArtImage:
else:
return False
def __hash__(self):
if self.datahash is None:
return 0
return hash(self.datahash.hash())
def set_data(self, data):
"""Store image data in a file, if data already exists in such file
it will be re-used and no file write occurs

View File

@@ -54,6 +54,8 @@ from picard.const import QUERY_LIMIT
class File(QtCore.QObject, Item):
metadata_images_changed = QtCore.pyqtSignal()
UNDEFINED = -1
PENDING = 0
NORMAL = 1
@@ -156,6 +158,7 @@ class File(QtCore.QObject, Item):
if acoustid:
self.metadata["acoustid_id"] = acoustid
self.metadata_images_changed.emit()
def has_error(self):
return self.state == File.ERROR

View File

@@ -55,6 +55,11 @@ class Metadata(dict):
def append_image(self, coverartimage):
self.images.append(coverartimage)
def set_front_image(self, coverartimage):
# First remove all front images
self.images[:] = [img for img in self.images if not img.is_front_image()]
self.images.append(coverartimage)
@property
def images_to_be_saved_to_tags(self):
if not config.setting["save_images_to_tags"]:

View File

@@ -19,6 +19,7 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
from functools import partial
from PyQt4 import QtCore
from picard import config, log
from picard.metadata import Metadata, run_track_metadata_processors
from picard.dataobj import DataObject
@@ -44,6 +45,8 @@ class TrackArtist(DataObject):
class Track(DataObject, Item):
metadata_images_changed = QtCore.pyqtSignal()
def __init__(self, id, album=None):
DataObject.__init__(self, id)
self.album = album
@@ -110,7 +113,7 @@ class Track(DataObject, Item):
return True
def can_view_info(self):
return self.num_linked_files == 1
return self.num_linked_files == 1 or self.metadata.images
def column(self, column):
m = self.metadata

View File

@@ -27,6 +27,8 @@ from picard.coverart.image import CoverArtImage, CoverArtImageError
from picard.track import Track
from picard.file import File
from picard.util import encode_filename, imageinfo
from picard.util.lrucache import LRUCache
from picard.const import MAX_COVERS_TO_STACK
if sys.platform == 'darwin':
try:
@@ -38,7 +40,6 @@ if sys.platform == 'darwin':
class ActiveLabel(QtGui.QLabel):
"""Clickable QLabel."""
clicked = QtCore.pyqtSignal()
@@ -83,7 +84,7 @@ class ActiveLabel(QtGui.QLabel):
class CoverArtThumbnail(ActiveLabel):
def __init__(self, active=False, drops=False, *args, **kwargs):
def __init__(self, active=False, drops=False, pixmap_cache=None, *args, **kwargs):
super(CoverArtThumbnail, self).__init__(active, drops, *args, **kwargs)
self.data = None
self.shadow = QtGui.QPixmap(":/images/CoverArtShadow.png")
@@ -93,6 +94,7 @@ class CoverArtThumbnail(ActiveLabel):
self.clicked.connect(self.open_release_page)
self.image_dropped.connect(self.fetch_remote_image)
self.related_images = list()
self._pixmap_cache = pixmap_cache
def __eq__(self, other):
if len(self.related_images) or len(other.related_images):
@@ -103,7 +105,20 @@ class CoverArtThumbnail(ActiveLabel):
def show(self):
self.set_data(self.data, True)
def set_data(self, data, force=False, pixmap=None):
def decorate_cover(self, pixmap):
offx, offy, w, h = (1, 1, 121, 121)
cover = QtGui.QPixmap(self.shadow)
pixmap = pixmap.scaled(w, h, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
painter = QtGui.QPainter(cover)
bgcolor = QtGui.QColor.fromRgb(0, 0, 0, 128)
painter.fillRect(QtCore.QRectF(offx, offy, w, h), bgcolor)
x = offx + (w - pixmap.width()) / 2
y = offy + (h - pixmap.height()) / 2
painter.drawPixmap(x, y, pixmap)
painter.end()
return cover
def set_data(self, data, force=False, has_common_images=True):
if not force and self.data == data:
return
@@ -111,43 +126,98 @@ class CoverArtThumbnail(ActiveLabel):
if not force and self.parent().isHidden():
return
cover = self.shadow
if self.data:
if pixmap is None:
if not self.data:
self.setPixmap(self.shadow)
return
w, h, displacements = (128, 128, 20)
key = hash(tuple(sorted(self.data)) + (has_common_images,))
try:
pixmap = self._pixmap_cache[key]
except KeyError:
if len(self.data) == 1:
pixmap = QtGui.QPixmap()
pixmap.loadFromData(self.data.data)
if not pixmap.isNull():
offx, offy, w, h = (1, 1, 121, 121)
cover = QtGui.QPixmap(self.shadow)
pixmap = pixmap.scaled(w, h, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
painter = QtGui.QPainter(cover)
bgcolor = QtGui.QColor.fromRgb(0, 0, 0, 128)
painter.fillRect(QtCore.QRectF(offx, offy, w, h), bgcolor)
x = offx + (w - pixmap.width()) / 2
y = offy + (h - pixmap.height()) / 2
painter.drawPixmap(x, y, pixmap)
pixmap.loadFromData(self.data[0].data)
pixmap = self.decorate_cover(pixmap)
else:
limited = len(self.data) > MAX_COVERS_TO_STACK
if limited:
data_to_paint = data[:MAX_COVERS_TO_STACK - 1]
offset = displacements * len(data_to_paint)
else:
data_to_paint = data
offset = displacements * (len(data_to_paint) - 1)
stack_width, stack_height = (w + offset, h + offset)
pixmap = QtGui.QPixmap(stack_width, stack_height)
bgcolor = self.palette().color(QtGui.QPalette.Window)
painter = QtGui.QPainter(pixmap)
painter.fillRect(QtCore.QRectF(0, 0, stack_width, stack_height), bgcolor)
cx = stack_width - w / 2
cy = h / 2
if limited:
x, y = (cx - self.shadow.width() / 2, cy - self.shadow.height() / 2)
for i in range(3):
painter.drawPixmap(x, y, self.shadow)
x -= displacements / 3
y += displacements / 3
cx -= displacements
cy += displacements
else:
cx = stack_width - w / 2
cy = h / 2
for image in reversed(data_to_paint):
if isinstance(image, QtGui.QPixmap):
thumb = image
else:
thumb = QtGui.QPixmap()
thumb.loadFromData(image.data)
thumb = self.decorate_cover(thumb)
x, y = (cx - thumb.width() / 2, cy - thumb.height() / 2)
painter.drawPixmap(x, y, thumb)
cx -= displacements
cy += displacements
if not has_common_images:
color = QtGui.QColor("darkgoldenrod")
border_length = 10
for k in range(border_length):
color.setAlpha(255 - k * 255 / border_length)
painter.setPen(color)
painter.drawLine(x, y - k - 1, x + 121 + k + 1, y - k - 1)
painter.drawLine(x + 121 + k + 2, y - 1 - k, x + 121 + k + 2, y + 121 + 4)
for k in range(5):
bgcolor.setAlpha(80 + k * 255 / 7)
painter.setPen(bgcolor)
painter.drawLine(x + 121 + 2, y + 121 + 2 + k, x + 121 + border_length + 2, y + 121 + 2 + k)
painter.end()
self.setPixmap(cover)
pixmap = pixmap.scaled(w, h, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
self._pixmap_cache[key] = pixmap
self.setPixmap(pixmap)
def set_metadata(self, metadata):
data = None
self.related_images = []
if metadata and metadata.images:
self.related_images = metadata.images
for image in metadata.images:
if image.is_front_image():
data = image
break
else:
data = [image for image in metadata.images if image.is_front_image()]
if not data:
# There's no front image, choose the first one available
data = metadata.images[0]
self.set_data(data)
data = [metadata.images[0]]
has_common_images = getattr(metadata, 'has_common_images', True)
self.set_data(data, has_common_images=has_common_images)
release = None
if metadata:
release = metadata.get("musicbrainz_albumid", None)
if release:
self.setActive(True)
self.setToolTip(_(u"View release on MusicBrainz"))
text = _(u"View release on MusicBrainz")
if hasattr(metadata, 'has_common_images'):
if has_common_images:
note = _(u'Common images on all tracks')
else:
note = _(u'Tracks contain different images')
text += '<br /><i>%s</i>' % note
self.setToolTip(text)
else:
self.setActive(False)
self.setToolTip("")
@@ -172,12 +242,13 @@ class CoverArtBox(QtGui.QGroupBox):
self.setStyleSheet('''QGroupBox{background-color:none;border:1px;}''')
self.setFlat(True)
self.item = None
self.pixmap_cache = LRUCache(40)
self.cover_art_label = QtGui.QLabel('')
self.cover_art_label.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter)
self.cover_art = CoverArtThumbnail(False, True, parent)
self.cover_art = CoverArtThumbnail(False, True, self.pixmap_cache, parent)
spacerItem = QtGui.QSpacerItem(40, 20, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding)
self.orig_cover_art_label = QtGui.QLabel('')
self.orig_cover_art = CoverArtThumbnail(False, False, parent)
self.orig_cover_art = CoverArtThumbnail(False, False, self.pixmap_cache, parent)
self.orig_cover_art_label.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter)
self.show_details_button = QtGui.QPushButton(_(u'Show more details'), self)
self.layout.addWidget(self.cover_art_label)
@@ -209,7 +280,7 @@ class CoverArtBox(QtGui.QGroupBox):
# We want to show the 2 coverarts only if they are different
# and orig_cover_art data is set and not the default cd shadow
if self.orig_cover_art.data is None or self.cover_art == self.orig_cover_art:
self.show_details_button.setHidden(len(self.cover_art.related_images) <= 1)
self.show_details_button.setHidden(len(self.cover_art.related_images) == 0)
self.orig_cover_art.setHidden(True)
self.cover_art_label.setText('')
self.orig_cover_art_label.setText('')
@@ -290,6 +361,7 @@ class CoverArtBox(QtGui.QGroupBox):
try:
coverartimage = CoverArtImage(
url=url.toString(),
types=[u'front'],
data=data
)
except CoverArtImageError as e:
@@ -297,21 +369,29 @@ class CoverArtBox(QtGui.QGroupBox):
return
if isinstance(self.item, Album):
album = self.item
album.metadata.append_image(coverartimage)
album.enable_update_metadata_images(False)
for track in album.tracks:
track.metadata.append_image(coverartimage)
track.metadata.set_front_image(coverartimage)
track.metadata_images_changed.emit()
for file in album.iterfiles():
file.metadata.append_image(coverartimage)
file.metadata.set_front_image(coverartimage)
file.metadata_images_changed.emit()
file.update()
album.enable_update_metadata_images(True)
elif isinstance(self.item, Track):
track = self.item
track.metadata.append_image(coverartimage)
track.album.enable_update_metadata_images(False)
track.metadata.set_front_image(coverartimage)
track.metadata_images_changed.emit()
for file in track.iterfiles():
file.metadata.append_image(coverartimage)
file.metadata.set_front_image(coverartimage)
file.metadata_images_changed.emit()
file.update()
track.album.enable_update_metadata_images(True)
elif isinstance(self.item, File):
file = self.item
file.metadata.append_image(coverartimage)
file.metadata.set_front_image(coverartimage)
file.metadata_images_changed.emit()
file.update()
self.cover_art.set_metadata(self.item.metadata)
self.show()

View File

@@ -24,6 +24,7 @@ from PyQt4 import QtGui, QtCore
from picard import log
from picard.file import File
from picard.track import Track
from picard.album import Album
from picard.coverart.image import CoverArtImageIOError
from picard.util import format_time, encode_filename, bytes2human, webbrowser2, union_sorted_lists
from picard.ui import PicardDialog
@@ -100,10 +101,14 @@ class InfoDialog(PicardDialog):
self.obj = obj
self.ui = Ui_InfoDialog()
self.display_existing_artwork = False
if isinstance(obj, File) and isinstance(obj.parent, Track) or \
isinstance(obj, Track):
if (isinstance(obj, File)
and isinstance(obj.parent, Track)
or isinstance(obj, Track)):
# Display existing artwork only if selected object is track object
# or linked to a track object
if getattr(obj, 'orig_metadata', None) is not None:
self.display_existing_artwork = True
elif isinstance(obj, Album) and obj.get_num_total_files() > 0:
self.display_existing_artwork = True
self.ui.setupUi(self)
@@ -236,8 +241,8 @@ class FileInfoDialog(InfoDialog):
InfoDialog.__init__(self, file, parent)
self.setWindowTitle(_("Info") + " - " + file.base_filename)
def _display_info_tab(self):
file = self.obj
@staticmethod
def format_file_info(file):
info = []
info.append((_('Filename:'), file.filename))
if '~format' in file.orig_metadata:
@@ -265,9 +270,13 @@ class FileInfoDialog(InfoDialog):
else:
ch = str(ch)
info.append((_('Channels:'), ch))
text = '<br/>'.join(map(lambda i: '<b>%s</b><br/>%s' %
return '<br/>'.join(map(lambda i: '<b>%s</b><br/>%s' %
(cgi.escape(i[0]),
cgi.escape(i[1])), info))
def _display_info_tab(self):
file = self.obj
text = FileInfoDialog.format_file_info(file)
self.ui.info.setText(text)
@@ -296,6 +305,29 @@ class AlbumInfoDialog(InfoDialog):
tabWidget.setTabText(tab_index, _("&Info"))
self.tab_hide(tab)
class TrackInfoDialog(FileInfoDialog):
def __init__(self, track, parent=None):
InfoDialog.__init__(self, track, parent)
self.setWindowTitle(_("Track Info"))
def _display_info_tab(self):
track = self.obj
tab = self.ui.info_tab
tabWidget = self.ui.tabWidget
tab_index = tabWidget.indexOf(tab)
if track.num_linked_files == 0:
tabWidget.setTabText(tab_index, _("&Info"))
self.tab_hide(tab)
return
tabWidget.setTabText(tab_index, _("&Info"))
text = ungettext("%i file in this track", "%i files in this track",
track.num_linked_files) % track.num_linked_files
info_files = [FileInfoDialog.format_file_info(file) for file in track.linked_files]
text += '<hr />' + '<hr />'.join(info_files)
self.ui.info.setText(text)
class ClusterInfoDialog(InfoDialog):

View File

@@ -33,7 +33,7 @@ from picard.ui.metadatabox import MetadataBox
from picard.ui.filebrowser import FileBrowser
from picard.ui.tagsfromfilenames import TagsFromFileNamesDialog
from picard.ui.options.dialog import OptionsDialog
from picard.ui.infodialog import FileInfoDialog, AlbumInfoDialog, ClusterInfoDialog
from picard.ui.infodialog import FileInfoDialog, AlbumInfoDialog, TrackInfoDialog, ClusterInfoDialog
from picard.ui.infostatus import InfoStatus
from picard.ui.passworddialog import PasswordDialog, ProxyDialog
from picard.ui.logview import LogView, HistoryView
@@ -844,6 +844,9 @@ class MainWindow(QtGui.QMainWindow):
elif isinstance(self.selected_objects[0], Cluster):
cluster = self.selected_objects[0]
dialog = ClusterInfoDialog(cluster, self)
elif isinstance(self.selected_objects[0], Track):
track = self.selected_objects[0]
dialog = TrackInfoDialog(track, self)
else:
file = self.tagger.get_files_from_objects(self.selected_objects)[0]
dialog = FileInfoDialog(file, self)
@@ -955,6 +958,9 @@ class MainWindow(QtGui.QMainWindow):
}
self.set_statusbar_message(msg, mparms, echo=None,
history=None)
elif isinstance(obj, Album):
metadata = obj.metadata
orig_metadata = obj.orig_metadata
elif obj.can_edit_tags():
metadata = obj.metadata

79
picard/util/lrucache.py Normal file
View File

@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
# Copyright (C) 2017 Antonio Larrosa <alarrosa@suse.com>
#
# 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.
class LRUCache(dict):
"""
Helper class to cache items using a Least Recently Used policy.
It's originally used to cache generated pixmaps in the CoverArtBox object
but it's generic enough to be used for other purposes if necessary.
The cache will never hold more than max_size items and the item least
recently used will be discarded.
>>> cache = LRUCache(3)
>>> cache['item1'] = 'some value'
>>> cache['item2'] = 'some other value'
>>> cache['item3'] = 'yet another value'
>>> cache['item1']
'some value'
>>> cache['item4'] = 'This will push item 2 out of the cache'
>>> cache['item2']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "lrucache.py", line 48, in __getitem__
return super(LRUCache, self).__getitem__(key)
KeyError: 'item2'
>>> cache['item5'] = 'This will push item3 out of the cache'
>>> cache['item3']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "lrucache.py", line 48, in __getitem__
return super(LRUCache, self).__getitem__(key)
KeyError: 'item3'
>>> cache['item1']
'some value'
"""
def __init__(self, max_size):
self._ordered_keys = []
self._max_size = max_size
def __getitem__(self, key):
if key in self:
self._ordered_keys.remove(key)
self._ordered_keys.insert(0, key)
return super(LRUCache, self).__getitem__(key)
def __setitem__(self, key, value):
if key in self:
self._ordered_keys.remove(key)
self._ordered_keys.insert(0, key)
r = super(LRUCache, self).__setitem__(key, value)
if len(self) > self._max_size:
item = self._ordered_keys.pop()
super(LRUCache, self).__delitem__(item)
return r
def __delitem__(self, key):
self._ordered_keys.remove(key)
super(LRUCache, self).__delitem__(key)