diff --git a/contrib/plugins/titlecase.py b/contrib/plugins/titlecase.py index bce28d3bf..2e03fa144 100644 --- a/contrib/plugins/titlecase.py +++ b/contrib/plugins/titlecase.py @@ -26,7 +26,7 @@ def utitle(string): for i in xrange(1, len(string)): s = string[i] # Special case apostrophe in the middle of a word. - if u"'" == s and string[i-1].isalpha(): cap = False + if s in u"’'" and string[i-1].isalpha(): cap = False elif iswbound(s): cap = True elif cap and s.isalpha(): cap = False diff --git a/picard/acoustid.py b/picard/acoustid.py index b2f81b824..f38766fca 100644 --- a/picard/acoustid.py +++ b/picard/acoustid.py @@ -94,18 +94,20 @@ class AcoustIDClient(QtCore.QObject): metadata_el = doc.append_child('metadata') acoustid_el = metadata_el.append_child('acoustid') recording_list_el = acoustid_el.append_child('recording_list') - acoustid_id = None - results = document.response[0].results[0].children.get('result') - if results: - result = results[0] - acoustid_id = result.id[0].text - if 'recordings' in result.children: - for recording in result.recordings[0].recording: - parse_recording(recording) + status = document.response[0].status[0].text + if status == 'ok': + results = document.response[0].results[0].children.get('result') + if results: + result = results[0] + file.metadata['acoustid_id'] = result.id[0].text + if 'recordings' in result.children: + for recording in result.recordings[0].recording: + parse_recording(recording) + else: + error_message = document.response[0].error[0].message[0].text + self.log.error("Fingerprint lookup failed: %r", error_message) - if acoustid_id is not None: - file.metadata['acoustid_id'] = acoustid_id next(doc, http, error) def _lookup_fingerprint(self, next, filename, result=None, error=None): @@ -127,7 +129,7 @@ class AcoustIDClient(QtCore.QObject): file.acoustid_length = length self.tagger.acoustidmanager.add(file, None) params['fingerprint'] = fingerprint - params['duration'] = str((file.metadata.length or 1000 * length) / 1000) + params['duration'] = str(length) else: type, trackid = result params['trackid'] = trackid diff --git a/picard/album.py b/picard/album.py index 2a9a81068..7a744002f 100644 --- a/picard/album.py +++ b/picard/album.py @@ -32,6 +32,7 @@ from picard.script import ScriptParser from picard.ui.item import Item from picard.util import format_time, queue, mbid_validate, asciipunct from picard.cluster import Cluster +from picard.collection import Collection, user_collections, load_user_collections from picard.mbxml import ( release_group_to_metadata, release_to_metadata, @@ -117,6 +118,15 @@ class Album(DataObject, Item): m['totaldiscs'] = release_node.medium_list[0].count + # Add album to collections + if "collection_list" in release_node.children: + for node in release_node.collection_list[0].collection: + if node.editor[0].text.lower() == self.config.setting["username"].lower(): + if node.id not in user_collections: + user_collections[node.id] = \ + Collection(node.id, node.name[0].text, node.release_list[0].count) + user_collections[node.id].releases.add(self.id) + # Run album metadata plugins try: run_album_metadata_processors(self, m, release_node) @@ -270,7 +280,8 @@ class Album(DataObject, Item): self._new_tracks = [] self._requests = 1 require_authentication = False - inc = ['release-groups', 'media', 'recordings', 'artist-credits', 'artists', 'aliases', 'labels', 'isrcs'] + inc = ['release-groups', 'media', 'recordings', 'artist-credits', + 'artists', 'aliases', 'labels', 'isrcs', 'collections'] if self.config.setting['release_ars'] or self.config.setting['track_ars']: inc += ['artist-rels', 'release-rels', 'url-rels', 'recording-rels', 'work-rels'] if self.config.setting['track_ars']: @@ -378,6 +389,9 @@ class Album(DataObject, Item): def can_refresh(self): return True + def can_view_info(self): + return bool(self.loaded and self.metadata and self.metadata.images) + def is_album_like(self): return True diff --git a/picard/cluster.py b/picard/cluster.py index 3ccfe729b..4fcd18149 100644 --- a/picard/cluster.py +++ b/picard/cluster.py @@ -162,7 +162,9 @@ class Cluster(QtCore.QObject, Item): self.tagger.move_files_to_album(self.files, match[1].id) def lookup_metadata(self): - """ Try to identify the cluster using the existing metadata. """ + """Try to identify the cluster using the existing metadata.""" + if self.lookup_task: + return self.tagger.window.set_statusbar_message(N_("Looking up the metadata for cluster %s..."), self.metadata['album']) self.lookup_task = self.tagger.xmlws.find_releases(self._lookup_finished, artist=self.metadata['albumartist'], @@ -278,6 +280,10 @@ class ClusterList(list, Item): def can_browser_lookup(self): return False + def lookup_metadata(self): + for cluster in self: + cluster.lookup_metadata() + class ClusterDict(object): diff --git a/picard/collection.py b/picard/collection.py new file mode 100644 index 000000000..c10ed5770 --- /dev/null +++ b/picard/collection.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# Copyright (C) 2013 Michael Wiencek +# +# 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. + +from PyQt4 import QtCore +from picard.util import partial + + +user_collections = {} + + +class Collection(QtCore.QObject): + + def __init__(self, id, name, size): + self.id = id + self.name = name + self.pending = set() + self.size = int(size) + self.releases = set() + + def add_releases(self, ids, callback): + ids = ids - self.pending + if ids: + self.pending.update(ids) + self.tagger.xmlws.put_to_collection(self.id, list(ids), + partial(self._add_finished, ids, callback)) + + def remove_releases(self, ids, callback): + ids = ids - self.pending + if ids: + self.pending.update(ids) + self.tagger.xmlws.delete_from_collection(self.id, list(ids), + partial(self._remove_finished, ids, callback)) + + def _add_finished(self, ids, callback, document, reply, error): + self.pending.difference_update(ids) + if not error: + count = len(ids) + self.releases.update(ids) + self.size += count + callback() + + self.tagger.window.set_statusbar_message( + ungettext('Added %i release to collection "%s"', + 'Added %i releases to collection "%s"', count) % (count, self.name)) + + def _remove_finished(self, ids, callback, document, reply, error): + self.pending.difference_update(ids) + if not error: + count = len(ids) + self.releases.difference_update(ids) + self.size -= count + callback() + + self.tagger.window.set_statusbar_message( + ungettext('Removed %i release from collection "%s"', + 'Removed %i releases from collection "%s"', count) % (count, self.name)) + + +def load_user_collections(callback=None): + tagger = QtCore.QObject.tagger + + def request_finished(document, reply, error): + if error: + tagger.window.set_statusbar_message(_("Error loading collections: %s"), unicode(reply.errorString())) + return + collection_list = document.metadata[0].collection_list[0] + if "collection" in collection_list.children: + new_collections = set() + + for node in collection_list.collection: + new_collections.add(node.id) + collection = user_collections.get(node.id) + if collection is None: + user_collections[node.id] = Collection(node.id, node.name[0].text, node.release_list[0].count) + else: + collection.name = node.name[0].text + collection.size = int(node.release_list[0].count) + + for id in set(user_collections.iterkeys()) - new_collections: + del user_collections[id] + + if callback: + callback() + + setting = QtCore.QObject.config.setting + if setting["username"] and setting["password"]: + tagger.xmlws.get_collection_list(partial(request_finished)) diff --git a/picard/const.py b/picard/const.py index c04eaceb5..f43802478 100644 --- a/picard/const.py +++ b/picard/const.py @@ -23,6 +23,7 @@ __builtin__.__dict__['N_'] = lambda a: a # AcoustID client API key ACOUSTID_KEY = '0zClDiGo' +ACOUSTID_HOST = 'api.acoustid.org' FPCALC_NAMES = ['fpcalc', 'pyfpcalc'] # Various Artists MBID diff --git a/picard/coverart.py b/picard/coverart.py index 4b534d85a..6ca896650 100644 --- a/picard/coverart.py +++ b/picard/coverart.py @@ -126,16 +126,16 @@ def _caa_json_downloaded(album, metadata, release, try_list, data, http, error): caa_types = QObject.config.setting["caa_image_types"].split() caa_types = map(unicode.lower, caa_types) for image in caa_data["images"]: + if QObject.config.setting["caa_approved_only"] and not image["approved"]: + continue + if not image["types"] and 'unknown' in caa_types: + _caa_append_image_to_trylist(try_list, image) imagetypes = map(unicode.lower, image["types"]) for imagetype in imagetypes: if imagetype == "front": caa_front_found = True if imagetype in caa_types: - if QObject.config.setting["caa_approved_only"]: - if image["approved"]: - _caa_append_image_to_trylist(try_list, image) - else: - _caa_append_image_to_trylist(try_list, image) + _caa_append_image_to_trylist(try_list, image) break if error or not caa_front_found: @@ -166,7 +166,40 @@ def coverart(album, metadata, release, try_list=None): # try_list will be None for the first call if try_list is None: try_list = [] - if QObject.config.setting['ca_provider_use_caa']: + + # MB web service indicates if CAA has artwork + # http://tickets.musicbrainz.org/browse/MBS-4536 + has_caa_artwork = False + caa_types = map(unicode.lower, + QObject.config.setting["caa_image_types"].split()) + + if 'cover_art_archive' in release.children: + caa_node = release.children['cover_art_archive'][0] + has_caa_artwork = bool(caa_node.artwork[0].text == 'true') + + if len(caa_types) == 2 and ('front' in caa_types or 'back' in caa_types): + # The OR cases are there to still download and process the CAA + # JSON file if front or back is enabled but not in the CAA and + # another type (that's neither front nor back) is enabled. + # For example, if both front and booklet are enabled and the + # CAA only has booklet images, the front element in the XML + # from the webservice will be false (thus front_in_caa is False + # as well) but it's still necessary to download the booklet + # images by using the fact that back is enabled but there are + # no back images in the CAA. + front_in_caa = caa_node.front[0].text == 'true' or 'front' not in caa_types + back_in_caa = caa_node.back[0].text == 'true' or 'back' not in caa_types + has_caa_artwork = has_caa_artwork and (front_in_caa or back_in_caa) + + elif len(caa_types) == 1 and ('front' in caa_types or 'back' in caa_types): + front_in_caa = caa_node.front[0].text == 'true' and 'front' in caa_types + back_in_caa = caa_node.back[0].text == 'true' and 'back' in caa_types + has_caa_artwork = has_caa_artwork and (front_in_caa or back_in_caa) + + if QObject.config.setting['ca_provider_use_caa'] and has_caa_artwork\ + and len(caa_types) > 0: + QObject.log.debug("There are suitable images in the cover art archive for %s" + % release.id) album._requests += 1 album.tagger.xmlws.download( "coverartarchive.org", 80, "/release/%s/" % @@ -174,6 +207,8 @@ def coverart(album, metadata, release, try_list=None): partial(_caa_json_downloaded, album, metadata, release, try_list), priority=True, important=True) else: + QObject.log.debug("There are no suitable images in the cover art archive for %s" + % release.id) _fill_try_list(album, release, try_list) _walk_try_list(album, metadata, release, try_list) @@ -248,7 +283,6 @@ def _process_asin_relation(try_list, relation): else: serverInfo = AMAZON_SERVER['amazon.com'] host = serverInfo['server'] - print "HOST", host path_l = AMAZON_IMAGE_PATH % (asin, serverInfo['id'], 'L') path_m = AMAZON_IMAGE_PATH % (asin, serverInfo['id'], 'M') _try_list_append_image_url(try_list, QUrl("http://%s:%s" % (host, path_l))) diff --git a/picard/coverartarchive.py b/picard/coverartarchive.py new file mode 100644 index 000000000..9e574177d --- /dev/null +++ b/picard/coverartarchive.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# Copyright (C) 2013 Laurent Monin +# +# 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. + +# list of types from http://musicbrainz.org/doc/Cover_Art/Types +# order of declaration is preserved in selection box +CAA_TYPES = [ + {'name': "front", 'title': N_("Front")}, + {'name': "back", 'title': N_("Back")}, + {'name': "booklet", 'title': N_("Booklet")}, + {'name': "medium", 'title': N_("Medium")}, + {'name': "tray", 'title': N_("Tray")}, + {'name': "obi", 'title': N_("Obi")}, + {'name': "spine", 'title': N_("Spine")}, + {'name': "track", 'title': N_("Track")}, + {'name': "sticker", 'title': N_("Sticker")}, + {'name': "other", 'title': N_("Other")}, + {'name': "unknown", 'title': N_("Unknown")}, # pseudo type, used for the no type case +] + +CAA_TYPES_SEPARATOR = ' ' #separator to use when joining/splitting list of types diff --git a/picard/file.py b/picard/file.py index a9e23ae94..ae5c52b9d 100644 --- a/picard/file.py +++ b/picard/file.py @@ -127,18 +127,19 @@ class File(QtCore.QObject, Item): def copy_metadata(self, metadata): acoustid = self.metadata["acoustid_id"] - preserve = self.config.setting["preserved_tags"].strip() - if preserve: - saved_metadata = {} - for tag in re.split(r"\s+", preserve): - values = self.orig_metadata.getall(tag) - if values: - saved_metadata[tag] = values - self.metadata.copy(metadata) - for tag, values in saved_metadata.iteritems(): - self.metadata.set(tag, values) - else: - self.metadata.copy(metadata) + preserve = self.config.setting["preserved_tags"].strip() + " ".join([ + "~bitrate", "~bits_per_sample", "~format", "~channels", + "~filename", "~dirname", "~extension"]) + saved_metadata = {} + + for tag in re.split(r"\s+", preserve): + values = self.orig_metadata.getall(tag) + if values: + saved_metadata[tag] = values + self.metadata.copy(metadata) + for tag, values in saved_metadata.iteritems(): + self.metadata.set(tag, values) + self.metadata["acoustid_id"] = acoustid def has_error(self): @@ -215,8 +216,8 @@ class File(QtCore.QObject, Item): self.base_filename = os.path.basename(new_filename) length = self.orig_metadata.length temp_info = {} - for info in ('~#bitrate', '~#sample_rate', '~#channels', - '~#bits_per_sample', '~format'): + for info in ('~bitrate', '~sample_rate', '~channels', + '~bits_per_sample', '~format'): temp_info[info] = self.orig_metadata[info] if self.config.setting["clear_existing_tags"]: self.orig_metadata.copy(self.metadata) @@ -248,13 +249,13 @@ class File(QtCore.QObject, Item): metadata[name] = sanitize_filename(metadata[name]) format = format.replace("\t", "").replace("\n", "") filename = ScriptParser().eval(format, metadata, self) - # replace incompatible characters - if settings["windows_compatible_filenames"] or sys.platform == "win32": - filename = replace_win32_incompat(filename) if settings["ascii_filenames"]: if isinstance(filename, unicode): filename = unaccent(filename) filename = replace_non_ascii(filename) + # replace incompatible characters + if settings["windows_compatible_filenames"] or sys.platform == "win32": + filename = replace_win32_incompat(filename) # remove null characters filename = filename.replace("\x00", "") return filename @@ -462,13 +463,13 @@ class File(QtCore.QObject, Item): if hasattr(file.info, 'length'): metadata.length = int(file.info.length * 1000) if hasattr(file.info, 'bitrate') and file.info.bitrate: - metadata['~#bitrate'] = file.info.bitrate / 1000.0 + metadata['~bitrate'] = file.info.bitrate / 1000.0 if hasattr(file.info, 'sample_rate') and file.info.sample_rate: - metadata['~#sample_rate'] = file.info.sample_rate + metadata['~sample_rate'] = file.info.sample_rate if hasattr(file.info, 'channels') and file.info.channels: - metadata['~#channels'] = file.info.channels + metadata['~channels'] = file.info.channels if hasattr(file.info, 'bits_per_sample') and file.info.bits_per_sample: - metadata['~#bits_per_sample'] = file.info.bits_per_sample + metadata['~bits_per_sample'] = file.info.bits_per_sample metadata['~format'] = self.__class__.__name__.replace('File', '') self._add_path_to_metadata(metadata) @@ -549,7 +550,9 @@ class File(QtCore.QObject, Item): self.tagger.move_file_to_nat(self, track.id, node=track) def lookup_metadata(self): - """ Try to identify the file using the existing metadata. """ + """Try to identify the file using the existing metadata.""" + if self.lookup_task: + return self.tagger.window.set_statusbar_message(N_("Looking up the metadata for file %s..."), self.filename) self.clear_lookup_task() metadata = self.metadata diff --git a/picard/formats/wav.py b/picard/formats/wav.py index 0c5062c0c..01f7ad544 100644 --- a/picard/formats/wav.py +++ b/picard/formats/wav.py @@ -30,9 +30,9 @@ class WAVFile(File): self.log.debug("Loading file %r", filename) f = wave.open(encode_filename(filename), "rb") metadata = Metadata() - metadata['~#channels'] = f.getnchannels() - metadata['~#bits_per_sample'] = f.getsampwidth() * 8 - metadata['~#sample_rate'] = f.getframerate() + metadata['~channels'] = f.getnchannels() + metadata['~bits_per_sample'] = f.getsampwidth() * 8 + metadata['~sample_rate'] = f.getframerate() metadata.length = 1000 * f.getnframes() / f.getframerate() metadata['~format'] = 'Microsoft WAVE' self._add_path_to_metadata(metadata) diff --git a/picard/mbxml.py b/picard/mbxml.py index 74802a712..d7b96ae2b 100644 --- a/picard/mbxml.py +++ b/picard/mbxml.py @@ -312,7 +312,11 @@ def release_group_to_metadata(node, m, config, release_group=None): for name, nodes in node.children.iteritems(): if not nodes: continue - if name == 'first_release_date': + if name == 'title': + m['~releasegroup'] = nodes[0].text + elif name == 'disambiguation': + m['~releasegroupcomment'] = nodes[0].text + elif name == 'first_release_date': m['originaldate'] = nodes[0].text elif name == 'tag_list': add_folksonomy_tags(nodes[0], release_group) diff --git a/picard/tagger.py b/picard/tagger.py index d49ac3254..3fe2e7932 100644 --- a/picard/tagger.py +++ b/picard/tagger.py @@ -62,6 +62,7 @@ from picard.file import File from picard.formats import open as open_file from picard.track import Track, NonAlbumTrack from picard.releasegroup import ReleaseGroup +from picard.collection import load_user_collections from picard.ui.mainwindow import MainWindow from picard.ui.itemviews import BaseTreeView from picard.plugin import PluginManager @@ -151,6 +152,8 @@ class Tagger(QtGui.QApplication): self.xmlws = XmlWebService() + load_user_collections() + # Initialize fingerprinting self._acoustid = acoustid.AcoustIDClient() self._acoustid.init() @@ -557,7 +560,7 @@ class Tagger(QtGui.QApplication): def autotag(self, objects): for obj in objects: - if isinstance(obj, (File, Cluster)) and not obj.lookup_task: + if obj.can_autotag(): obj.lookup_metadata() # ======================================================================= diff --git a/picard/ui/collectionmenu.py b/picard/ui/collectionmenu.py new file mode 100644 index 000000000..19ac3a992 --- /dev/null +++ b/picard/ui/collectionmenu.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# Copyright (C) 2013 Michael Wiencek +# +# 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. + +from PyQt4 import QtCore, QtGui +from picard.collection import user_collections, load_user_collections + + +class CollectionMenu(QtGui.QMenu): + + def __init__(self, albums, *args): + QtGui.QMenu.__init__(self, *args) + self.ids = set(a.id for a in albums) + self.actions_by_id = {} + self.separator = self.addSeparator() + self.refresh_action = self.addAction(_("Refresh List")) + self.update_collections() + + def update_collections(self): + for id, collection in user_collections.iteritems(): + action = self.actions_by_id.get(collection.id) + if action: + action.defaultWidget().updateText() + else: + action = QtGui.QWidgetAction(self) + action.setDefaultWidget(CollectionCheckBox(self, collection)) + self.insertAction(self.separator, action) + self.actions_by_id[collection.id] = action + + for id, action in self.actions_by_id.items(): + if id not in user_collections: + self.removeAction(action) + del self.actions_by_id[id] + + def refresh_list(self): + self.refresh_action.setEnabled(False) + load_user_collections(self.update_collections) + + def mouseReleaseEvent(self, event): + # Not using self.refresh_action.triggered because it closes the menu + if self.actionAt(event.pos()) == self.refresh_action and self.refresh_action.isEnabled(): + self.refresh_list() + + +class CollectionCheckBox(QtGui.QCheckBox): + + def __init__(self, menu, collection): + self.menu = menu + self.collection = collection + QtGui.QCheckBox.__init__(self, self.label()) + + releases = collection.releases & menu.ids + if len(releases) == len(menu.ids): + self.setCheckState(QtCore.Qt.Checked) + elif not releases: + self.setCheckState(QtCore.Qt.Unchecked) + else: + self.setCheckState(QtCore.Qt.PartiallyChecked) + + def nextCheckState(self): + ids = self.menu.ids + if ids & self.collection.pending: + return + diff = ids - self.collection.releases + if diff: + self.collection.add_releases(diff, self.updateText) + self.setCheckState(QtCore.Qt.Checked) + else: + self.collection.remove_releases(ids & self.collection.releases, self.updateText) + self.setCheckState(QtCore.Qt.Unchecked) + + def updateText(self): + self.setText(self.label()) + + def label(self): + c = self.collection + return ungettext("%s (%i release)", "%s (%i releases)", c.size) % (c.name, c.size) diff --git a/picard/ui/coverartbox.py b/picard/ui/coverartbox.py index 8f21a7f10..f3f5ac9d9 100644 --- a/picard/ui/coverartbox.py +++ b/picard/ui/coverartbox.py @@ -101,10 +101,15 @@ class CoverArtBox(QtGui.QGroupBox): 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(121, 121, QtCore.Qt.IgnoreAspectRatio, QtCore.Qt.SmoothTransformation) + pixmap = pixmap.scaled(w, h, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) painter = QtGui.QPainter(cover) - painter.drawPixmap(1, 1, pixmap) + 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() self.coverArt.setPixmap(cover) @@ -151,7 +156,7 @@ class CoverArtBox(QtGui.QGroupBox): path = encode_filename(unicode(url.toLocalFile())) if os.path.exists(path): f = open(path, 'rb') - mime = 'image/png' if '.png' in path.lower() else 'image/jpeg' + mime = 'image/png' if path.lower().endswith('.png') else 'image/jpeg' data = f.read() f.close() self.load_remote_image(mime, data) diff --git a/picard/ui/infodialog.py b/picard/ui/infodialog.py index 7afbe8327..7289911a4 100644 --- a/picard/ui/infodialog.py +++ b/picard/ui/infodialog.py @@ -25,18 +25,50 @@ from picard.ui.ui_infodialog import Ui_InfoDialog class InfoDialog(QtGui.QDialog): - def __init__(self, file, parent=None): + def __init__(self, obj, parent=None): QtGui.QDialog.__init__(self, parent) - self.file = file + self.obj = obj self.ui = Ui_InfoDialog() self.ui.setupUi(self) self.ui.buttonBox.accepted.connect(self.accept) self.ui.buttonBox.rejected.connect(self.reject) - self.setWindowTitle(_("Info") + " - " + file.base_filename) - self.load_info() + self.setWindowTitle(_("Info")) + self._display_tabs() - def load_info(self): - file = self.file + def _display_tabs(self): + self._display_info_tab() + self._display_artwork_tab() + + def _display_artwork_tab(self): + tab = self.ui.artwork_tab + images = self.obj.metadata.images + if not images: + self.tab_hide(tab) + return + + for image in images: + data = image["data"] + item = QtGui.QListWidgetItem() + pixmap = QtGui.QPixmap() + pixmap.loadFromData(data) + icon = QtGui.QIcon(pixmap) + item.setIcon(icon) + self.ui.artwork_list.addItem(item) + + def tab_hide(self, widget): + tab = self.ui.tabWidget + index = tab.indexOf(widget) + tab.removeTab(index) + + +class FileInfoDialog(InfoDialog): + + def __init__(self, file, parent=None): + InfoDialog.__init__(self, file, parent) + self.setWindowTitle(_("Info") + " - " + file.base_filename) + + def _display_info_tab(self): + file = self.obj info = [] info.append((_('Filename:'), file.filename)) if '~format' in file.orig_metadata: @@ -54,14 +86,14 @@ class InfoDialog(QtGui.QDialog): pass if file.orig_metadata.length: info.append((_('Length:'), format_time(file.orig_metadata.length))) - if '~#bitrate' in file.orig_metadata: - info.append((_('Bitrate:'), '%s kbps' % file.orig_metadata['~#bitrate'])) - if '~#sample_rate' in file.orig_metadata: - info.append((_('Sample rate:'), '%s Hz' % file.orig_metadata['~#sample_rate'])) - if '~#bits_per_sample' in file.orig_metadata: - info.append((_('Bits per sample:'), str(file.orig_metadata['~#bits_per_sample']))) - if '~#channels' in file.orig_metadata: - ch = file.orig_metadata['~#channels'] + if '~bitrate' in file.orig_metadata: + info.append((_('Bitrate:'), '%s kbps' % file.orig_metadata['~bitrate'])) + if '~sample_rate' in file.orig_metadata: + info.append((_('Sample rate:'), '%s Hz' % file.orig_metadata['~sample_rate'])) + if '~bits_per_sample' in file.orig_metadata: + info.append((_('Bits per sample:'), str(file.orig_metadata['~bits_per_sample']))) + if '~channels' in file.orig_metadata: + ch = file.orig_metadata['~channels'] if ch == 1: ch = _('Mono') elif ch == 2: ch = _('Stereo') else: ch = str(ch) @@ -69,11 +101,13 @@ class InfoDialog(QtGui.QDialog): text = '
'.join(map(lambda i: '%s
%s' % i, info)) self.ui.info.setText(text) - for image in file.metadata.images: - data = image["data"] - item = QtGui.QListWidgetItem() - pixmap = QtGui.QPixmap() - pixmap.loadFromData(data) - icon = QtGui.QIcon(pixmap) - item.setIcon(icon) - self.ui.artwork_list.addItem(item) + +class AlbumInfoDialog(InfoDialog): + + def __init__(self, album, parent=None): + InfoDialog.__init__(self, album, parent) + self.setWindowTitle(_("Album Info")) + + def _display_info_tab(self): + tab = self.ui.info_tab + self.tab_hide(tab) diff --git a/picard/ui/itemviews.py b/picard/ui/itemviews.py index 263c570fe..057fcbe1b 100644 --- a/picard/ui/itemviews.py +++ b/picard/ui/itemviews.py @@ -28,6 +28,7 @@ from picard.util import encode_filename, icontheme, partial from picard.config import Option, TextOption from picard.plugin import ExtensionPoint from picard.ui.ratingwidget import RatingWidget +from picard.ui.collectionmenu import CollectionMenu class BaseAction(QtGui.QAction): @@ -271,6 +272,8 @@ class BaseTreeView(QtGui.QTreeWidget): menu.addAction(self.window.analyze_action) plugin_actions = list(_file_actions) elif isinstance(obj, Album): + if can_view_info: + menu.addAction(self.window.view_info_action) menu.addAction(self.window.browser_lookup_action) menu.addSeparator() menu.addAction(self.window.refresh_action) @@ -279,24 +282,31 @@ class BaseTreeView(QtGui.QTreeWidget): menu.addAction(self.window.save_action) menu.addAction(self.window.remove_action) + bottom_separator = False + if isinstance(obj, Album) and not isinstance(obj, NatAlbum) and obj.loaded: releases_menu = QtGui.QMenu(_("&Other versions"), menu) menu.addSeparator() menu.addMenu(releases_menu) loading = releases_menu.addAction(_('Loading...')) loading.setEnabled(False) + bottom_separator = True - def _add_other_versions(): - releases_menu.removeAction(loading) - for version in obj.release_group.versions: - action = releases_menu.addAction(version["name"]) - action.setCheckable(True) - if obj.id == version["id"]: - action.setChecked(True) - action.triggered.connect(partial(obj.switch_release_version, version["id"])) + if len(self.selectedIndexes()) == len(MainPanel.columns): + def _add_other_versions(): + releases_menu.removeAction(loading) + for version in obj.release_group.versions: + action = releases_menu.addAction(version["name"]) + action.setCheckable(True) + if obj.id == version["id"]: + action.setChecked(True) + action.triggered.connect(partial(obj.switch_release_version, version["id"])) - _add_other_versions() if obj.release_group.loaded else \ - obj.release_group.load_versions(_add_other_versions) + _add_other_versions() if obj.release_group.loaded else \ + obj.release_group.load_versions(_add_other_versions) + releases_menu.setEnabled(True) + else: + releases_menu.setEnabled(False) if self.config.setting["enable_ratings"] and \ len(self.window.selected_objects) == 1 and isinstance(obj, Track): @@ -306,6 +316,12 @@ class BaseTreeView(QtGui.QTreeWidget): menu.addAction(action) menu.addSeparator() + selected_albums = [a for a in self.window.selected_objects if type(a) == Album] + if selected_albums: + if not bottom_separator: + menu.addSeparator() + menu.addMenu(CollectionMenu(selected_albums, _("Collections"), menu)) + if plugin_actions: plugin_menu = QtGui.QMenu(_("&Plugins"), menu) plugin_menu.setIcon(self.panel.icon_plugins) @@ -324,6 +340,7 @@ class BaseTreeView(QtGui.QTreeWidget): action_menu.addAction(action) if isinstance(obj, Cluster) or isinstance(obj, ClusterList) or isinstance(obj, Album): + menu.addSeparator() menu.addAction(self.expand_all_action) menu.addAction(self.collapse_all_action) @@ -453,7 +470,9 @@ class BaseTreeView(QtGui.QTreeWidget): def activate_item(self, index): obj = self.itemFromIndex(index).obj - if obj.can_view_info(): + # Double-clicking albums should expand them. The album info can be + # viewed by using the toolbar button. + if not isinstance(obj, Album) and obj.can_view_info(): self.window.view_info() def add_cluster(self, cluster, parent_item=None): diff --git a/picard/ui/mainwindow.py b/picard/ui/mainwindow.py index 5f457122c..64b8e889f 100644 --- a/picard/ui/mainwindow.py +++ b/picard/ui/mainwindow.py @@ -24,6 +24,7 @@ import os.path from picard.file import File from picard.track import Track +from picard.album import Album from picard.config import Option, BoolOption, TextOption from picard.formats import supported_formats from picard.ui.coverartbox import CoverArtBox @@ -32,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 InfoDialog +from picard.ui.infodialog import FileInfoDialog, AlbumInfoDialog from picard.ui.passworddialog import PasswordDialog from picard.util import icontheme, webbrowser2, find_existing_path from picard.util.cdrom import get_cdrom_drives @@ -676,8 +677,12 @@ been merged with that of single artist albums."""), return ret == QtGui.QMessageBox.Yes def view_info(self): - file = self.tagger.get_files_from_objects(self.selected_objects)[0] - dialog = InfoDialog(file, self) + if isinstance(self.selected_objects[0], Album): + album = self.selected_objects[0] + dialog = AlbumInfoDialog(album, self) + else: + file = self.tagger.get_files_from_objects(self.selected_objects)[0] + dialog = FileInfoDialog(file, self) dialog.exec_() def cluster(self): diff --git a/picard/ui/options/cover.py b/picard/ui/options/cover.py index 036b9a3d7..bc4a07953 100644 --- a/picard/ui/options/cover.py +++ b/picard/ui/options/cover.py @@ -21,6 +21,45 @@ from PyQt4 import QtCore, QtGui from picard.config import BoolOption, IntOption, TextOption from picard.ui.options import OptionsPage, register_options_page from picard.ui.ui_options_cover import Ui_CoverOptionsPage +from picard.coverartarchive import CAA_TYPES, CAA_TYPES_SEPARATOR + + +class CAATypesSelector(object): + def __init__(self, widget, enabled_types=''): + self.widget = widget + self._enabled_types = enabled_types.split(CAA_TYPES_SEPARATOR) + self._items = {} + self._populate() + + def _populate(self): + for caa_type in CAA_TYPES: + enabled = caa_type["name"] in self._enabled_types + self._add_item(caa_type, enabled=enabled) + + def _add_item(self, typ, enabled=False): + item = QtGui.QListWidgetItem(self.widget) + item.setText(typ['title']) + tooltip = u"CAA: %(name)s" % typ + item.setToolTip(tooltip) + if enabled: + state = QtCore.Qt.Checked + else: + state = QtCore.Qt.Unchecked + item.setCheckState(state) + self._items[item] = typ + + def get_selected_types(self): + types = [] + for item, typ in self._items.iteritems(): + if item.checkState() == QtCore.Qt.Checked: + types.append(typ['name']) + return types + + def get_selected_types_as_string(self): + return CAA_TYPES_SEPARATOR.join(self.get_selected_types()) + + def __str__(self): + return self.get_selected_types_as_string() class CoverOptionsPage(OptionsPage): @@ -67,7 +106,10 @@ class CoverOptionsPage(OptionsPage): self.ui.gb_caa.setEnabled(self.config.setting["ca_provider_use_caa"]) self.ui.cb_image_size.setCurrentIndex(self.config.setting["caa_image_size"]) - self.ui.le_image_types.setText(self.config.setting["caa_image_types"]) + widget = self.ui.caa_types_selector_1 + self._selector = CAATypesSelector(widget, self.config.setting["caa_image_types"]) + self.config.setting["caa_image_types"] = \ + self._selector.get_selected_types_as_string() self.ui.cb_approved_only.setChecked(self.config.setting["caa_approved_only"]) self.ui.cb_type_as_filename.setChecked(self.config.setting["caa_image_type_as_filename"]) self.connect(self.ui.caprovider_caa, QtCore.SIGNAL("toggled(bool)"), @@ -88,7 +130,8 @@ class CoverOptionsPage(OptionsPage): self.ui.caprovider_whitelist.isChecked() self.config.setting["caa_image_size"] =\ self.ui.cb_image_size.currentIndex() - self.config.setting["caa_image_types"] = self.ui.le_image_types.text() + self.config.setting["caa_image_types"] = \ + self._selector.get_selected_types_as_string() self.config.setting["caa_approved_only"] =\ self.ui.cb_approved_only.isChecked() self.config.setting["caa_image_type_as_filename"] = \ diff --git a/picard/ui/ui_options_cover.py b/picard/ui/ui_options_cover.py index 677a687a7..522f106bd 100644 --- a/picard/ui/ui_options_cover.py +++ b/picard/ui/ui_options_cover.py @@ -2,8 +2,8 @@ # Form implementation generated from reading ui file 'ui/options_cover.ui' # -# Created: Tue Nov 20 18:25:21 2012 -# by: PyQt4 UI code generator 4.9.5 +# Created: Tue Jan 22 12:56:46 2013 +# by: PyQt4 UI code generator 4.9.6 # # WARNING! All changes made in this file will be lost! @@ -12,7 +12,16 @@ from PyQt4 import QtCore, QtGui try: _fromUtf8 = QtCore.QString.fromUtf8 except AttributeError: - _fromUtf8 = lambda s: s + def _fromUtf8(s): + return s + +try: + _encoding = QtGui.QApplication.UnicodeUTF8 + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig, _encoding) +except AttributeError: + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig) class Ui_CoverOptionsPage(object): def setupUi(self, CoverOptionsPage): @@ -89,12 +98,22 @@ class Ui_CoverOptionsPage(object): self.label_2 = QtGui.QLabel(self.gb_caa) self.label_2.setObjectName(_fromUtf8("label_2")) self.verticalLayout_3.addWidget(self.label_2) - self.le_image_types = QtGui.QLineEdit(self.gb_caa) - self.le_image_types.setObjectName(_fromUtf8("le_image_types")) - self.verticalLayout_3.addWidget(self.le_image_types) - self.caa_types_help = QtGui.QLabel(self.gb_caa) - self.caa_types_help.setObjectName(_fromUtf8("caa_types_help")) - self.verticalLayout_3.addWidget(self.caa_types_help) + self.caa_types_selector_1 = QtGui.QListWidget(self.gb_caa) + sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.MinimumExpanding, QtGui.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.caa_types_selector_1.sizePolicy().hasHeightForWidth()) + self.caa_types_selector_1.setSizePolicy(sizePolicy) + self.caa_types_selector_1.setMaximumSize(QtCore.QSize(16777215, 80)) + self.caa_types_selector_1.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.caa_types_selector_1.setTabKeyNavigation(True) + self.caa_types_selector_1.setProperty("showDropIndicator", False) + self.caa_types_selector_1.setAlternatingRowColors(True) + self.caa_types_selector_1.setSelectionMode(QtGui.QAbstractItemView.NoSelection) + self.caa_types_selector_1.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) + self.caa_types_selector_1.setVerticalScrollMode(QtGui.QAbstractItemView.ScrollPerPixel) + self.caa_types_selector_1.setObjectName(_fromUtf8("caa_types_selector_1")) + self.verticalLayout_3.addWidget(self.caa_types_selector_1) self.cb_approved_only = QtGui.QCheckBox(self.gb_caa) self.cb_approved_only.setObjectName(_fromUtf8("cb_approved_only")) self.verticalLayout_3.addWidget(self.cb_approved_only) @@ -113,8 +132,18 @@ class Ui_CoverOptionsPage(object): self.retranslateUi(CoverOptionsPage) QtCore.QObject.connect(self.save_images_to_tags, QtCore.SIGNAL(_fromUtf8("clicked(bool)")), self.cb_embed_front_only.setEnabled) QtCore.QMetaObject.connectSlotsByName(CoverOptionsPage) - CoverOptionsPage.setTabOrder(self.save_images_to_tags, self.save_images_to_files) + CoverOptionsPage.setTabOrder(self.save_images_to_tags, self.cb_embed_front_only) + CoverOptionsPage.setTabOrder(self.cb_embed_front_only, self.save_images_to_files) CoverOptionsPage.setTabOrder(self.save_images_to_files, self.cover_image_filename) + CoverOptionsPage.setTabOrder(self.cover_image_filename, self.save_images_overwrite) + CoverOptionsPage.setTabOrder(self.save_images_overwrite, self.caprovider_amazon) + CoverOptionsPage.setTabOrder(self.caprovider_amazon, self.caprovider_cdbaby) + CoverOptionsPage.setTabOrder(self.caprovider_cdbaby, self.caprovider_caa) + CoverOptionsPage.setTabOrder(self.caprovider_caa, self.caprovider_whitelist) + CoverOptionsPage.setTabOrder(self.caprovider_whitelist, self.cb_image_size) + CoverOptionsPage.setTabOrder(self.cb_image_size, self.caa_types_selector_1) + CoverOptionsPage.setTabOrder(self.caa_types_selector_1, self.cb_approved_only) + CoverOptionsPage.setTabOrder(self.cb_approved_only, self.cb_type_as_filename) def retranslateUi(self, CoverOptionsPage): self.rename_files.setTitle(_("Location")) @@ -134,7 +163,6 @@ class Ui_CoverOptionsPage(object): self.cb_image_size.setItemText(1, _("500 px")) self.cb_image_size.setItemText(2, _("Full size")) self.label_2.setText(_("Download only images of the following types:")) - self.caa_types_help.setText(_("Types are separated by spaces, and are not case-sensitive.")) self.cb_approved_only.setText(_("Download only approved images")) self.cb_type_as_filename.setText(_("Use the first image type as the filename. This will not change the filename of front images.")) diff --git a/picard/webservice.py b/picard/webservice.py index 97a4d018d..362f5bd6d 100644 --- a/picard/webservice.py +++ b/picard/webservice.py @@ -26,16 +26,18 @@ Asynchronous XML web service. import sys import re import time +import os.path from collections import deque, defaultdict from PyQt4 import QtCore, QtNetwork, QtXml +from PyQt4.QtGui import QDesktopServices from PyQt4.QtCore import QUrl from picard import version_string from picard.util import partial -from picard.const import ACOUSTID_KEY +from picard.const import ACOUSTID_KEY, ACOUSTID_HOST REQUEST_DELAY = defaultdict(lambda: 1000) -REQUEST_DELAY[('api.acoustid.org', 80)] = 333 +REQUEST_DELAY[(ACOUSTID_HOST, 80)] = 333 REQUEST_DELAY[("coverartarchive.org", 80)] = 0 USER_AGENT_STRING = 'MusicBrainz%%20Picard-%s' % version_string @@ -111,6 +113,7 @@ class XmlWebService(QtCore.QObject): def __init__(self, parent=None): QtCore.QObject.__init__(self, parent) self.manager = QtNetwork.QNetworkAccessManager() + self.set_cache() self.setup_proxy() self.manager.finished.connect(self._process_reply) self._last_request_times = {} @@ -128,6 +131,16 @@ class XmlWebService(QtCore.QObject): "DELETE": self.manager.deleteResource } + def set_cache(self, cache_size_in_mb=100): + cache = QtNetwork.QNetworkDiskCache() + location = QDesktopServices.storageLocation(QDesktopServices.CacheLocation) + cache.setCacheDirectory(os.path.join(unicode(location), u'picard')) + cache.setMaximumCacheSize(cache_size_in_mb * 1024 * 1024) + self.manager.setCache(cache) + self.log.debug("NetworkDiskCache dir: %s", cache.cacheDirectory()) + self.log.debug("NetworkDiskCache size: %s / %s", cache.cacheSize(), + cache.maximumCacheSize()) + def setup_proxy(self): self.proxy = QtNetwork.QNetworkProxy() if self.config.setting["use_proxy"]: @@ -138,13 +151,17 @@ class XmlWebService(QtCore.QObject): self.proxy.setPassword(self.config.setting["proxy_password"]) self.manager.setProxy(self.proxy) - def _start_request(self, method, host, port, path, data, handler, xml, mblogin=False): + def _start_request(self, method, host, port, path, data, handler, xml, + mblogin=False, cacheloadcontrol=None): self.log.debug("%s http://%s:%d%s", method, host, port, path) url = QUrl.fromEncoded("http://%s:%d%s" % (host, port, path)) if mblogin: url.setUserName(self.config.setting["username"]) url.setPassword(self.config.setting["password"]) request = QtNetwork.QNetworkRequest(url) + if cacheloadcontrol is not None: + request.setAttribute(QtNetwork.QNetworkRequest.CacheLoadControlAttribute, + cacheloadcontrol) request.setRawHeader("User-Agent", "MusicBrainz-Picard/%s" % version_string) if data is not None: if method == "POST" and host == self.config.setting["server_host"]: @@ -176,10 +193,14 @@ class XmlWebService(QtCore.QObject): return error = int(reply.error()) redirect = reply.attribute(QtNetwork.QNetworkRequest.RedirectionTargetAttribute).toUrl() - self.log.debug("Received reply for %s: HTTP %d (%s)", + fromCache = reply.attribute(QtNetwork.QNetworkRequest.SourceIsFromCacheAttribute).toBool() + cached = ' (CACHED)' if fromCache else '' + self.log.debug("Received reply for %s: HTTP %d (%s) %s", reply.request().url().toString(), reply.attribute(QtNetwork.QNetworkRequest.HttpStatusCodeAttribute).toInt()[0], - reply.attribute(QtNetwork.QNetworkRequest.HttpReasonPhraseAttribute).toString()) + reply.attribute(QtNetwork.QNetworkRequest.HttpReasonPhraseAttribute).toString(), + cached + ) if handler is not None: if error: self.log.error("Network request error for %s: %s (QT code %d, HTTP code %d)", @@ -210,7 +231,8 @@ class XmlWebService(QtCore.QObject): redirect_port, # retain path, query string and anchors from redirect URL redirect.toString(QUrl.FormattingOption(QUrl.RemoveAuthority | QUrl.RemoveScheme)), - handler, xml, priority=True, important=True) + handler, xml, priority=True, important=True, + cacheloadcontrol=request.attribute(QtNetwork.QNetworkRequest.CacheLoadControlAttribute)) elif xml: xml_handler = XmlHandler() xml_handler.init() @@ -223,8 +245,10 @@ class XmlWebService(QtCore.QObject): handler(str(reply.readAll()), reply, error) reply.close() - def get(self, host, port, path, handler, xml=True, priority=False, important=False, mblogin=False): - func = partial(self._start_request, "GET", host, port, path, None, handler, xml, mblogin) + def get(self, host, port, path, handler, xml=True, priority=False, + important=False, mblogin=False, cacheloadcontrol=None): + func = partial(self._start_request, "GET", host, port, path, None, + handler, xml, mblogin, cacheloadcontrol=cacheloadcontrol) return self.add_task(func, host, port, priority, important=important) def post(self, host, port, path, data, handler, xml=True, priority=True, important=True, mblogin=True): @@ -369,7 +393,7 @@ class XmlWebService(QtCore.QObject): return '&'.join(filters) def query_acoustid(self, handler, **args): - host, port = 'api.acoustid.org', 80 + host, port = ACOUSTID_HOST, 80 body = self._encode_acoustid_args(args) return self.post(host, port, '/v2/lookup', body, handler, mblogin=False) @@ -381,10 +405,38 @@ class XmlWebService(QtCore.QObject): args['mbid.%d' % i] = str(submission.trackid) if submission.puid: args['puid.%d' % i] = str(submission.puid) - host, port = 'api.acoustid.org', 80 + host, port = ACOUSTID_HOST, 80 body = self._encode_acoustid_args(args) return self.post(host, port, '/v2/submit', body, handler, mblogin=False) - def download(self, host, port, path, handler, priority=False, important=False): - return self.get(host, port, path, handler, xml=False, priority=priority, important=important) + def download(self, host, port, path, handler, priority=False, + important=False, cacheloadcontrol=None): + return self.get(host, port, path, handler, xml=False, priority=priority, + important=important, cacheloadcontrol=cacheloadcontrol) + def get_collection(self, id, handler, limit=100, offset=0): + host, port = self.config.setting['server_host'], self.config.setting['server_port'] + path = "/ws/2/collection" + if id is not None: + inc = ["releases", "artist-credits", "media"] + path += "/%s/releases?inc=%s&limit=%d&offset=%d" % (id, "+".join(inc), limit, offset) + return self.get(host, port, path, handler, priority=True, important=True, mblogin=True) + + def get_collection_list(self, handler): + return self.get_collection(None, handler) + + def _collection_request(self, id, releases): + while releases: + ids = ";".join(releases if len(releases) <= 400 else releases[:400]) + releases = releases[400:] + yield "/ws/2/collection/%s/releases/%s?client=%s" % (id, ids, USER_AGENT_STRING) + + def put_to_collection(self, id, releases, handler): + host, port = self.config.setting['server_host'], self.config.setting['server_port'] + for path in self._collection_request(id, releases): + self.put(host, port, path, "", handler) + + def delete_from_collection(self, id, releases, handler): + host, port = self.config.setting['server_host'], self.config.setting['server_port'] + for path in self._collection_request(id, releases): + self.delete(host, port, path, handler) diff --git a/setup.py b/setup.py index 3ff8c7aae..77b0aa9d3 100755 --- a/setup.py +++ b/setup.py @@ -250,9 +250,14 @@ class picard_build_ui(Command): def run(self): from PyQt4 import uic - _translate_re = re.compile( - r'QtGui\.QApplication.translate\(.*?, (.*?), None, ' - r'QtGui\.QApplication\.UnicodeUTF8\)') + _translate_re = ( + re.compile( + r'QtGui\.QApplication.translate\(.*?, (.*?), None, ' + r'QtGui\.QApplication\.UnicodeUTF8\)'), + re.compile( + r'\b_translate\(.*?, (.*?), None\)') + ) + for uifile in glob.glob("ui/*.ui"): pyfile = "ui_%s.py" % os.path.splitext(os.path.basename(uifile))[0] pyfile = os.path.join("picard", "ui", pyfile) @@ -260,7 +265,9 @@ class picard_build_ui(Command): log.info("compiling %s -> %s", uifile, pyfile) tmp = StringIO() uic.compileUi(uifile, tmp) - source = _translate_re.sub(r'_(\1)', tmp.getvalue()) + source = tmp.getvalue() + for r in list(_translate_re): + source = r.sub(r'_(\1)', source) f = open(pyfile, "w") f.write(source) f.close() diff --git a/ui/options_cover.ui b/ui/options_cover.ui index 04fb78dec..352de2df0 100644 --- a/ui/options_cover.ui +++ b/ui/options_cover.ui @@ -167,14 +167,41 @@ - - - - - - Types are separated by spaces, and are not case-sensitive. + + + + 0 + 0 + - + + + 16777215 + 80 + + + + Qt::ScrollBarAsNeeded + + + true + + + false + + + true + + + QAbstractItemView::NoSelection + + + QAbstractItemView::SelectRows + + + QAbstractItemView::ScrollPerPixel + + @@ -216,8 +243,18 @@ save_images_to_tags + cb_embed_front_only save_images_to_files cover_image_filename + save_images_overwrite + caprovider_amazon + caprovider_cdbaby + caprovider_caa + caprovider_whitelist + cb_image_size + caa_types_selector_1 + cb_approved_only + cb_type_as_filename