Merge branch 'image-class-2'

Conflicts:
	picard/ui/infodialog.py
This commit is contained in:
Wieland Hoffmann
2014-04-08 13:13:22 +02:00
13 changed files with 260 additions and 134 deletions

View File

@@ -26,7 +26,7 @@ import re
import traceback
from functools import partial
from picard import config, log
from picard.metadata import is_front_image
from picard.metadata import Image, is_front_image
from picard.util import mimetype, parse_amazon_url
from picard.const import CAA_HOST, CAA_PORT
from PyQt4.QtCore import QUrl, QObject
@@ -84,12 +84,21 @@ def _coverart_downloaded(album, metadata, release, try_list, coverinfos, data, h
QObject.tagger.window.set_statusbar_message(N_("Coverart %s downloaded"),
http.url().toString())
mime = mimetype.get_from_data(data, default="image/jpeg")
filename = None
if not is_front_image(coverinfos) and config.setting["caa_image_type_as_filename"]:
filename = coverinfos['type']
metadata.add_image(mime, data, filename, coverinfos)
for track in album._new_tracks:
track.metadata.add_image(mime, data, filename, coverinfos)
try:
metadata.make_and_add_image(mime, data,
imagetype=coverinfos['type'],
comment=coverinfos['desc'])
for track in album._new_tracks:
track.metadata.make_and_add_image(mime, data,
imagetype=coverinfos['type'],
comment=coverinfos['desc'])
except (IOError, OSError), e:
album.error_append(e.message)
album._finalize_loading(error=True)
# It doesn't make sense to store/download more images if we can't
# save them in the temporary folder, abort.
return
# If the image already was a front image, there might still be some
# other front images in the try_list - remove them.

View File

@@ -310,60 +310,13 @@ class File(QtCore.QObject, Item):
shutil.move(encode_filename(old_filename), encode_filename(new_filename))
return new_filename
def _make_image_filename(self, image_filename, dirname, metadata):
image_filename = self._script_to_filename(image_filename, metadata)
if not image_filename:
image_filename = "cover"
if os.path.isabs(image_filename):
filename = image_filename
else:
filename = os.path.join(dirname, image_filename)
if config.setting['windows_compatibility'] or sys.platform == 'win32':
filename = filename.replace('./', '_/').replace('.\\', '_\\')
return encode_filename(filename)
def _save_images(self, dirname, metadata):
"""Save the cover images to disk."""
if not metadata.images:
return
default_filename = self._make_image_filename(
config.setting["cover_image_filename"], dirname, metadata)
overwrite = config.setting["save_images_overwrite"]
counters = defaultdict(lambda: 0)
for image in metadata.images:
filename = image["filename"]
data = image["data"]
mime = image["mime"]
if filename is None:
filename = default_filename
else:
filename = self._make_image_filename(filename, dirname, metadata)
image_filename = filename
ext = mimetype.get_extension(mime, ".jpg")
if counters[filename] > 0:
image_filename = "%s (%d)" % (filename, counters[filename])
counters[filename] = counters[filename] + 1
while os.path.exists(image_filename + ext) and not overwrite:
if os.path.getsize(image_filename + ext) == len(data):
log.debug("Identical file size, not saving %r", image_filename)
break
image_filename = "%s (%d)" % (filename, counters[filename])
counters[filename] = counters[filename] + 1
else:
new_filename = image_filename + ext
# Even if overwrite is enabled we don't need to write the same
# image multiple times
if (os.path.exists(new_filename) and
os.path.getsize(new_filename) == len(data)):
log.debug("Identical file size, not saving %r", image_filename)
continue
log.debug("Saving cover images to %r", image_filename)
new_dirname = os.path.dirname(image_filename)
if not os.path.isdir(new_dirname):
os.makedirs(new_dirname)
f = open(image_filename + ext, "wb")
f.write(data)
f.close()
image.save(dirname, metadata, counters)
def _move_additional_files(self, old_filename, new_filename):
"""Move extra files, like playlists..."""

View File

@@ -63,7 +63,7 @@ class APEv2File(File):
if '\0' in values.value:
descr, data = values.value.split('\0', 1)
mime = mimetype.get_from_data(data, descr, 'image/jpeg')
metadata.add_image(mime, data)
metadata.make_and_add_image(mime, data)
# skip EXTERNAL and BINARY values
if values.kind != mutagen.apev2.TEXT:
continue
@@ -150,8 +150,8 @@ class APEv2File(File):
if not save_this_image_to_tags(image):
continue
cover_filename = 'Cover Art (Front)'
cover_filename += mimetype.get_extension(image["mime"], '.jpg')
tags['Cover Art (Front)'] = mutagen.apev2.APEValue(cover_filename + '\0' + image["data"], mutagen.apev2.BINARY)
cover_filename += mimetype.get_extension(image.mimetype, '.jpg')
tags['Cover Art (Front)'] = mutagen.apev2.APEValue(cover_filename + '\0' + image.data, mutagen.apev2.BINARY)
break # can't save more than one item with the same name
# (mp3tags does this, but it's against the specs)
tags.save(encode_filename(filename))

View File

@@ -141,11 +141,8 @@ class ASFFile(File):
if name == 'WM/Picture':
for image in values:
(mime, data, type, description) = unpack_image(image.value)
extras = {
'desc': description,
'type': image_type_from_id3_num(type)
}
metadata.add_image(mime, data, extras=extras)
metadata.make_and_add_image(mime, data, comment=description,
imagetype=image_type_from_id3_num(type))
continue
elif name not in self.__RTRANS:
continue
@@ -170,9 +167,9 @@ class ASFFile(File):
for image in metadata.images:
if not save_this_image_to_tags(image):
continue
tag_data = pack_image(image["mime"], image["data"],
image_type_as_id3_num(image['type']),
image['desc'])
tag_data = pack_image(image.mimetype, image.data,
image_type_as_id3_num(image.imagetype),
image.description)
cover.append(ASFByteArrayAttribute(tag_data))
if cover:
file.tags['WM/Picture'] = cover

View File

@@ -259,11 +259,8 @@ class ID3File(File):
else:
log.error("Invalid %s value '%s' dropped in %r", frameid, frame.text[0], filename)
elif frameid == 'APIC':
extras = {
'desc': frame.desc,
'type': image_type_from_id3_num(frame.type)
}
metadata.add_image(frame.mime, frame.data, extras=extras)
metadata.make_and_add_image(frame.mime, frame.data, comment=frame.desc,
imagetype=image_type_from_id3_num(frame.type))
elif frameid == 'POPM':
# Rating in ID3 ranges from 0 to 255, normalize this to the range 0 to 5
if frame.email == config.setting['rating_user_email']:
@@ -318,7 +315,7 @@ class ID3File(File):
# any description.
counters = defaultdict(lambda: 0)
for image in metadata.images:
desc = desctag = image['desc']
desc = desctag = image.description
if not save_this_image_to_tags(image):
continue
if counters[desc] > 0:
@@ -328,10 +325,10 @@ class ID3File(File):
desctag = "(%i)" % counters[desc]
counters[desc] += 1
tags.add(id3.APIC(encoding=0,
mime=image["mime"],
type=image_type_as_id3_num(image['type']),
mime=image.mimetype,
type=image_type_as_id3_num(image.imagetype),
desc=desctag,
data=image["data"]))
data=image.data))
tmcl = mutagen.id3.TMCL(encoding=encoding, people=[])
tipl = mutagen.id3.TIPL(encoding=encoding, people=[])

View File

@@ -141,9 +141,9 @@ class MP4File(File):
elif name == "covr":
for value in values:
if value.imageformat == value.FORMAT_JPEG:
metadata.add_image("image/jpeg", value)
metadata.make_and_add_image("image/jpeg", value)
elif value.imageformat == value.FORMAT_PNG:
metadata.add_image("image/png", value)
metadata.make_and_add_image("image/png", value)
self._info(metadata, file)
return metadata
@@ -194,11 +194,11 @@ class MP4File(File):
for image in metadata.images:
if not save_this_image_to_tags(image):
continue
mime = image["mime"]
mime = image.mimetype
if mime == "image/jpeg":
covr.append(MP4Cover(image["data"], MP4Cover.FORMAT_JPEG))
covr.append(MP4Cover(image.data, MP4Cover.FORMAT_JPEG))
elif mime == "image/png":
covr.append(MP4Cover(image["data"], MP4Cover.FORMAT_PNG))
covr.append(MP4Cover(image.data, MP4Cover.FORMAT_PNG))
if covr:
file.tags["covr"] = covr

View File

@@ -96,27 +96,22 @@ class VCommentFile(File):
name = "totaldiscs"
elif name == "metadata_block_picture":
image = mutagen.flac.Picture(base64.standard_b64decode(value))
extras = {
'desc': image.desc,
'type': image_type_from_id3_num(image.type)
}
metadata.add_image(image.mime, image.data, extras=extras)
metadata.make_and_add_image(image.mime, image.data,
comment=image.desc,
imagetype=image_type_from_id3_num(image.type))
continue
elif name in self.__translate:
name = self.__translate[name]
metadata.add(name, value)
if self._File == mutagen.flac.FLAC:
for image in file.pictures:
extras = {
'desc': image.desc,
'type': image_type_from_id3_num(image.type)
}
metadata.add_image(image.mime, image.data, extras=extras)
metadata.make_and_add_image(image.mime, image.data, comment=image.desc,
imagetype=image_type_from_id3_num(image.type))
# Read the unofficial COVERART tags, for backward compatibillity only
if not "metadata_block_picture" in file.tags:
try:
for index, data in enumerate(file["COVERART"]):
metadata.add_image(file["COVERARTMIME"][index],
metadata.make_and_add_image(file["COVERARTMIME"][index],
base64.standard_b64decode(data)
)
except KeyError:
@@ -175,10 +170,10 @@ class VCommentFile(File):
if not save_this_image_to_tags(image):
continue
picture = mutagen.flac.Picture()
picture.data = image["data"]
picture.mime = image["mime"]
picture.desc = image['desc']
picture.type = image_type_as_id3_num(image['type'])
picture.data = image.data
picture.mime = image.mimetype
picture.desc = image.description
picture.type = image_type_as_id3_num(image.imagetype)
if self._File == mutagen.flac.FLAC:
file.add_picture(picture)
else:

View File

@@ -15,13 +15,29 @@
#
# 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.
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
# USA.
import os.path
import shutil
import sys
import tempfile
import traceback
from hashlib import md5
from os import fdopen, unlink
from PyQt4.QtCore import QObject
from picard import config
from picard import config, log
from picard.plugin import ExtensionPoint
from picard.similarity import similarity2
from picard.util import load_release_type_scores
from picard.util import (
encode_filename,
load_release_type_scores,
mimetype as mime,
replace_non_ascii,
replace_win32_incompat,
unaccent,
)
from picard.mbxml import artist_credit_from_node
MULTI_VALUED_JOINER = '; '
@@ -39,9 +55,105 @@ def is_front_image(image):
def save_this_image_to_tags(image):
if not config.setting["save_only_front_images_to_tags"]:
return True
if is_front_image(image):
return True
return False
return image.is_front_image
class Image(object):
"""Wrapper around images. Instantiating an object of this class can raise
an IOError or OSError due to the usage of tempfiles underneath.
"""
def __init__(self, data, mimetype="image/jpeg", imagetype="front",
comment="", filename=None, datahash=""):
self.description = comment
(fd, self._tempfile_filename) = tempfile.mkstemp(prefix="picard")
with fdopen(fd, "wb") as imagefile:
imagefile.write(data)
log.debug("Saving image (hash=%s) to %r" % (datahash,
self._tempfile_filename))
self.datalength = len(data)
self.extension = mime.get_extension(mime, ".jpg")
self.filename = filename
self.imagetype = imagetype
self.is_front_image = imagetype == "front"
self.mimetype = mimetype
def _make_image_filename(self, filename, dirname, metadata):
if config.setting["ascii_filenames"]:
if isinstance(filename, unicode):
filename = unaccent(filename)
filename = replace_non_ascii(filename)
if not filename:
filename = "cover"
if not os.path.isabs(filename):
filename = os.path.join(dirname, filename)
# replace incompatible characters
if config.setting["windows_compatibility"] or sys.platform == "win32":
filename = replace_win32_incompat(filename)
# remove null characters
filename = filename.replace("\x00", "")
return encode_filename(filename)
def save(self, dirname, metadata, counters):
"""Saves this image.
:dirname: The name of the directory that contains the audio file
:metadata: A metadata object
:counters: A dictionary mapping filenames to the amount of how many
images with that filename were already saved in `dirname`.
"""
if self.filename is not None:
log.debug("Using the custom file name %s", self.filename)
filename = self.filename
elif config.setting["caa_image_type_as_filename"]:
log.debug("Using image type %s", self.imagetype)
filename = self.imagetype
else:
log.debug("Using default file name %s",
config.setting["cover_image_filename"])
filename = config.setting["cover_image_filename"]
filename = self._make_image_filename(filename, dirname, metadata)
overwrite = config.setting["save_images_overwrite"]
ext = self.extension
image_filename = filename
if counters[filename] > 0:
image_filename = "%s (%d)" % (filename, counters[filename])
counters[filename] = counters[filename] + 1
while os.path.exists(image_filename + ext) and not overwrite:
if os.path.getsize(image_filename + ext) == self.datalength:
log.debug("Identical file size, not saving %r", image_filename)
break
image_filename = "%s (%d)" % (filename, counters[filename])
counters[filename] = counters[filename] + 1
else:
new_filename = image_filename + ext
# Even if overwrite is enabled we don't need to write the same
# image multiple times
if (os.path.exists(new_filename) and
os.path.getsize(new_filename) == self.datalength):
log.debug("Identical file size, not saving %r", image_filename)
return
log.debug("Saving cover images to %r", image_filename)
new_dirname = os.path.dirname(image_filename)
if not os.path.isdir(new_dirname):
os.makedirs(new_dirname)
shutil.copyfile(self._tempfile_filename, new_filename)
@property
def data(self):
"""Reads the data from the temporary file created for this image. May
raise IOErrors or OSErrors.
"""
with open(self._tempfile_filename, "rb") as imagefile:
return imagefile.read()
def _delete(self):
log.debug("Unlinking %s", self._tempfile_filename)
try:
unlink(self._tempfile_filename)
except OSError, e:
log.error(traceback.format_exc())
class Metadata(dict):
@@ -61,26 +173,30 @@ class Metadata(dict):
self.images = []
self.length = 0
def add_image(self, mime, data, filename=None, extras=None):
"""Adds the image ``data`` to this Metadata object.
def make_and_add_image(self, mime, data, filename=None, comment="",
imagetype="front"):
"""Build a new image object from ``data`` and adds it to this Metadata
object. If an image with the same MD5 hash has already been added to
any Metadata object, that file will be reused.
Arguments:
mime -- The mimetype of the image
data -- The image data
filename -- The image filename, without an extension
extras -- extra informations about image as dict
'desc' : image description or comment, default to ''
'type' : main type as a string, default to 'front'
'front': if set, CAA front flag is true for this image
comment -- image description or comment, default to ''
imagetype -- main type as a string, default to 'front'
"""
imagedict = {'mime': mime,
'data': data,
'filename': filename,
'type': 'front',
'desc': ''}
if extras is not None:
imagedict.update(extras)
self.images.append(imagedict)
m = md5()
m.update(data)
datahash = m.hexdigest()
QObject.tagger.images.lock()
image = QObject.tagger.images[datahash]
if image is None:
image = Image(data, mime, imagetype, comment, filename,
datahash=datahash)
QObject.tagger.images[datahash] = image
QObject.tagger.images.unlock()
self.images.append(image)
def remove_image(self, index):
self.images.pop(index)

View File

@@ -30,7 +30,9 @@ import os.path
import re
import shutil
import signal
import socket
import sys
from collections import defaultdict
from functools import partial
from itertools import chain
@@ -76,6 +78,7 @@ from picard.util import (
check_io_encoding,
uniqify,
is_hidden_path,
LockableDefaultDict
)
from picard.webservice import XmlWebService
@@ -107,6 +110,25 @@ class Tagger(QtGui.QApplication):
self.save_thread_pool = QtCore.QThreadPool(self)
self.save_thread_pool.setMaxThreadCount(1)
if not sys.platform == "win32":
# Set up signal handling
# It's not possible to call all available functions from signal
# handlers, therefore we need to set up a QSocketNotifier to listen
# on a socket. Sending data through a socket can be done in a
# signal handler, so we use the socket to notify the application of
# the signal.
# This code is adopted from
# https://qt-project.org/doc/qt-4.8/unix-signals.html
self.signalfd = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0)
self.signalnotifier = QtCore.QSocketNotifier(self.signalfd[1].fileno(),
QtCore.QSocketNotifier.Read, self)
self.signalnotifier.activated.connect(self.sighandler)
signal.signal(signal.SIGHUP, self.signal)
signal.signal(signal.SIGINT, self.signal)
signal.signal(signal.SIGTERM, self.signal)
# Setup logging
if debug or "PICARD_DEBUG" in os.environ:
log.log_levels = log.log_levels | log.LOG_DEBUG
@@ -165,6 +187,7 @@ class Tagger(QtGui.QApplication):
self.albums = {}
self.release_groups = {}
self.mbid_redirects = {}
self.images = LockableDefaultDict(lambda: None)
self.unmatched_files = UnmatchedFiles()
self.nats = None
self.window = MainWindow()
@@ -205,6 +228,8 @@ class Tagger(QtGui.QApplication):
self.nats.update()
def exit(self):
log.debug("exit")
map(lambda i: i._delete(), self.images.itervalues())
self.stopping = True
self._acoustid.done()
self.thread_pool.waitForDone()
@@ -543,6 +568,18 @@ class Tagger(QtGui.QApplication):
def instance(cls):
return cls.__instance
def signal(self, signum, frame):
log.debug("signal %i received", signum)
# Send a notification about a received signal from the signal handler
# to Qt.
self.signalfd[0].sendall("a")
def sighandler(self):
self.signalnotifier.setEnabled(False)
self.exit()
self.quit()
self.signalnotifier.setEnabled(True)
def help():
print """Usage: %s [OPTIONS] [FILE] [FILE] ...
@@ -577,4 +614,5 @@ def main(localedir=None, autoupdate=True):
elif opt in ("-d", "--debug"):
kwargs["debug"] = True
tagger = Tagger(args, localedir, autoupdate, **kwargs)
tagger.startTimer(1000)
sys.exit(tagger.run())

View File

@@ -23,7 +23,6 @@ from picard import config, log
from picard.album import Album
from picard.track import Track
from picard.file import File
from picard.metadata import is_front_image
from picard.util import webbrowser2, encode_filename
@@ -104,7 +103,7 @@ class CoverArtBox(QtGui.QGroupBox):
if self.data:
if pixmap is None:
pixmap = QtGui.QPixmap()
pixmap.loadFromData(self.data["data"])
pixmap.loadFromData(self.data.data)
if not pixmap.isNull():
offx, offy, w, h = (1, 1, 121, 121)
cover = QtGui.QPixmap(self.shadow)
@@ -123,7 +122,7 @@ class CoverArtBox(QtGui.QGroupBox):
data = None
if metadata and metadata.images:
for image in metadata.images:
if is_front_image(image):
if image.is_front_image:
data = image
break
else:
@@ -189,16 +188,16 @@ class CoverArtBox(QtGui.QGroupBox):
self.__set_data([mime, data], pixmap=pixmap)
if isinstance(self.item, Album):
album = self.item
album.metadata.add_image(mime, data)
album.metadata.make_and_add_image(mime, data)
for track in album.tracks:
track.metadata.add_image(mime, data)
track.metadata.make_and_add_image(mime, data)
for file in album.iterfiles():
file.metadata.add_image(mime, data)
file.metadata.make_and_add_image(mime, data)
elif isinstance(self.item, Track):
track = self.item
track.metadata.add_image(mime, data)
track.metadata.make_and_add_image(mime, data)
for file in track.iterfiles():
file.metadata.add_image(mime, data)
file.metadata.make_and_add_image(mime, data)
elif isinstance(self.item, File):
file = self.item
file.metadata.add_image(mime, data)
file.metadata.make_and_add_image(mime, data)

View File

@@ -49,7 +49,11 @@ class InfoDialog(PicardDialog):
return
for image in images:
data = image["data"]
try:
data = image.data
except (OSError, IOError), e:
log.error(traceback.format_exc())
continue
size = len(data)
item = QtGui.QListWidgetItem()
pixmap = QtGui.QPixmap()
@@ -105,8 +109,8 @@ class FileInfoDialog(InfoDialog):
ch = str(ch)
info.append((_('Channels:'), ch))
text = '<br/>'.join(map(lambda i: '<b>%s</b><br/>%s' %
(cgi.escape(i[0]),
cgi.escape(i[1])), info))
(QtCore.Qt.escape(i[0]),
QtCore.Qt.escape(i[1])), info))
self.ui.info.setText(text)
@@ -124,7 +128,7 @@ class AlbumInfoDialog(InfoDialog):
if album.errors:
tabWidget.setTabText(tab_index, _("&Errors"))
text = '<br />'.join(map(lambda s: '<font color="darkred">%s</font>' %
'<br />'.join(unicode(cgi.escape(s))
'<br />'.join(unicode(QtCore.Qt.escape(s))
.replace('\t', ' ')
.replace(' ', '&nbsp;')
.splitlines()

View File

@@ -27,6 +27,19 @@ from PyQt4 import QtCore
from encodings import rot_13
from string import Template
from functools import partial
from collections import defaultdict
class LockableDefaultDict(defaultdict):
def __init__(self, default):
defaultdict.__init__(self, default)
self.__lock = QtCore.QReadWriteLock()
def lock(self):
self.__lock.lockForWrite()
def unlock(self):
self.__lock.unlock()
def asciipunct(s):

View File

@@ -1,11 +1,14 @@
import os.path
import picard.formats
import unittest
import shutil
from tempfile import mkstemp
from PyQt4 import QtCore
from picard.util import LockableDefaultDict
from picard import config, log
from picard.metadata import Metadata
import picard.formats
from PyQt4 import QtCore
from tempfile import mkstemp
settings = {
@@ -33,6 +36,7 @@ class FakeTagger(QtCore.QObject):
QtCore.QObject.config = config
QtCore.QObject.log = log
self.tagger_stats_changed.connect(self.emit)
self.images = LockableDefaultDict(lambda: None)
def emit(self, *args):
pass
@@ -504,6 +508,7 @@ class TestCoverArt(unittest.TestCase):
QtCore.QObject.tagger = FakeTagger()
def _tear_down(self):
map(lambda i: i._delete(), QtCore.QObject.tagger.images.itervalues())
os.unlink(self.filename)
def test_asf(self):
@@ -544,13 +549,13 @@ class TestCoverArt(unittest.TestCase):
f = picard.formats.open(self.filename)
metadata = Metadata()
imgdata = tests[t]['head'] + dummyload
metadata.add_image(tests[t]['mime'], imgdata)
metadata.make_and_add_image(tests[t]['mime'], imgdata)
f._save(self.filename, metadata)
f = picard.formats.open(self.filename)
loaded_metadata = f._load(self.filename)
image = loaded_metadata.images[0]
self.assertEqual(image["mime"], tests[t]['mime'])
self.assertEqual(image["data"], imgdata)
self.assertEqual(image.mimetype, tests[t]['mime'])
self.assertEqual(image.data, imgdata)
finally:
self._tear_down()