From 07b4d36e977e1a4971f2a8e6a15694bea47b7264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lalinsk=C3=BD?= Date: Sun, 30 Dec 2012 17:10:49 +0100 Subject: [PATCH 01/37] Fix error when there are no tags (filename is not a local variable) --- picard/file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/picard/file.py b/picard/file.py index 9dcb0904e..90f37b88e 100644 --- a/picard/file.py +++ b/picard/file.py @@ -111,9 +111,9 @@ class File(QtCore.QObject, Item): def _copy_loaded_metadata(self, metadata): metadata['~length'] = format_time(metadata.length) if 'title' not in metadata: - metadata['title'] = filename + metadata['title'] = self.filename if 'tracknumber' not in metadata: - match = re.match("(?:track)?\s*(?:no|nr)?\s*(\d+)", filename, re.I) + match = re.match("(?:track)?\s*(?:no|nr)?\s*(\d+)", self.filename, re.I) if match: try: tracknumber = int(match.group(1)) From 29a79b5e5483d7456b3fbb27238667fdf48e9e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lalinsk=C3=BD?= Date: Sun, 30 Dec 2012 17:11:36 +0100 Subject: [PATCH 02/37] Create a constant with the AcoustID hostname --- picard/const.py | 1 + picard/webservice.py | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) 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/webservice.py b/picard/webservice.py index 97a4d018d..76e565367 100644 --- a/picard/webservice.py +++ b/picard/webservice.py @@ -31,11 +31,11 @@ from PyQt4 import QtCore, QtNetwork, QtXml 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 @@ -369,7 +369,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,7 +381,7 @@ 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) From be552f84eff860fdc6b3c0d269f742f6d972a0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lalinsk=C3=BD?= Date: Sun, 30 Dec 2012 17:14:07 +0100 Subject: [PATCH 03/37] Handle Unicode apostrophe as well http://forums.musicbrainz.org/viewtopic.php?pid=20715 --- contrib/plugins/titlecase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 128931f687d090bd41a3ef8e083ebf9ddd130471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lalinsk=C3=BD?= Date: Sun, 30 Dec 2012 17:16:02 +0100 Subject: [PATCH 04/37] Fix logical conflict (different fix from bitmap) --- picard/file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/picard/file.py b/picard/file.py index 357039230..a9e23ae94 100644 --- a/picard/file.py +++ b/picard/file.py @@ -112,9 +112,9 @@ class File(QtCore.QObject, Item): filename, _ = os.path.splitext(self.base_filename) metadata['~length'] = format_time(metadata.length) if 'title' not in metadata: - metadata['title'] = self.filename + metadata['title'] = filename if 'tracknumber' not in metadata: - match = re.match("(?:track)?\s*(?:no|nr)?\s*(\d+)", self.filename, re.I) + match = re.match("(?:track)?\s*(?:no|nr)?\s*(\d+)", filename, re.I) if match: try: tracknumber = int(match.group(1)) From ea928b4f395695f9a1a8c91215a4bc4535ea0a8d Mon Sep 17 00:00:00 2001 From: Wieland Hoffmann Date: Tue, 8 Jan 2013 16:47:02 +0100 Subject: [PATCH 05/37] Rename and preserve tags containing # so they can be used --- picard/file.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/picard/file.py b/picard/file.py index a9e23ae94..2ef3eb0e0 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): @@ -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) From 994eb75c5474d6ac35a92a99c29f61d32ca0e71c Mon Sep 17 00:00:00 2001 From: Michael Wiencek Date: Tue, 8 Jan 2013 02:34:24 -0600 Subject: [PATCH 06/37] Add basic collections management support (PICARD-84) Adds a "Collections" submenu to the album context menu. The code is mostly based on my GSoC branch from a couple years ago, but I made some heavy changes. --- picard/album.py | 12 ++++- picard/collection.py | 103 ++++++++++++++++++++++++++++++++++++ picard/tagger.py | 3 ++ picard/ui/collectionmenu.py | 92 ++++++++++++++++++++++++++++++++ picard/ui/itemviews.py | 11 ++++ picard/webservice.py | 26 +++++++++ 6 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 picard/collection.py create mode 100644 picard/ui/collectionmenu.py diff --git a/picard/album.py b/picard/album.py index 2a9a81068..13d5b9054 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,14 @@ 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.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 +279,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']: 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/tagger.py b/picard/tagger.py index d49ac3254..5efe2dafa 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() 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/itemviews.py b/picard/ui/itemviews.py index 263c570fe..7acb3291b 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): @@ -279,12 +280,15 @@ 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) @@ -306,6 +310,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 +334,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) diff --git a/picard/webservice.py b/picard/webservice.py index 76e565367..b0dee45bf 100644 --- a/picard/webservice.py +++ b/picard/webservice.py @@ -388,3 +388,29 @@ class XmlWebService(QtCore.QObject): 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 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) From 7c134bbd136570a6d08264552000eb49bb38dd35 Mon Sep 17 00:00:00 2001 From: Wieland Hoffmann Date: Tue, 8 Jan 2013 21:53:42 +0100 Subject: [PATCH 07/37] fixup! Rename and preserve tags containing # so they can be used --- picard/file.py | 6 +++--- picard/formats/wav.py | 6 +++--- picard/ui/infodialog.py | 16 ++++++++-------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/picard/file.py b/picard/file.py index 2ef3eb0e0..bd10d2161 100644 --- a/picard/file.py +++ b/picard/file.py @@ -128,7 +128,7 @@ class File(QtCore.QObject, Item): def copy_metadata(self, metadata): acoustid = self.metadata["acoustid_id"] preserve = self.config.setting["preserved_tags"].strip() + " ".join([ - "~bitrate", "~bits_per_sample", "format", "~channels", + "~bitrate", "~bits_per_sample", "~format", "~channels", "~filename", "~dirname", "~extension"]) saved_metadata = {} @@ -216,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) 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/ui/infodialog.py b/picard/ui/infodialog.py index 7afbe8327..074c33c65 100644 --- a/picard/ui/infodialog.py +++ b/picard/ui/infodialog.py @@ -54,14 +54,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) From a19f98cfef28fe521a34087856d1349f1e98b46a Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Mon, 7 Jan 2013 17:46:53 +0100 Subject: [PATCH 08/37] If option "caa_approved_only" is set, skip non-approved images asap. --- picard/coverart.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/picard/coverart.py b/picard/coverart.py index 4b534d85a..28bd4ae5f 100644 --- a/picard/coverart.py +++ b/picard/coverart.py @@ -126,16 +126,14 @@ 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 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: From ba5b275672e43c35f2c565c91386223fcb4b36ae Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Fri, 11 Jan 2013 03:15:47 +0100 Subject: [PATCH 09/37] Remove forgotten debug print --- picard/coverart.py | 1 - 1 file changed, 1 deletion(-) diff --git a/picard/coverart.py b/picard/coverart.py index 4b534d85a..c9ba30fb0 100644 --- a/picard/coverart.py +++ b/picard/coverart.py @@ -248,7 +248,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))) From 43cc06842cb1d55e8ab635b361252ab4d33b1ba1 Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Sat, 12 Jan 2013 21:46:21 +0100 Subject: [PATCH 10/37] CAA downloader: fix CAA download of 'unknown' type images When no type is set for an image, CAA is returning an empty list for types. But Picard user has no way to specify he wants to also download images of unknown type using the option 'Download only images of the following types'. I added a special type named 'unknown' and modified _caa_json_downloaded() to handle this case. --- picard/coverart.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/picard/coverart.py b/picard/coverart.py index 09a846415..f43d0b814 100644 --- a/picard/coverart.py +++ b/picard/coverart.py @@ -128,6 +128,8 @@ def _caa_json_downloaded(album, metadata, release, try_list, data, http, error): 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": From bd455346303514ef7c0d6b63b1ed6a06d42d3198 Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Sun, 13 Jan 2013 00:05:43 +0100 Subject: [PATCH 11/37] CoverArtBox: fix incorrect extension matching (.png) Code was incorrectly setting 'image/png' type for file 'img.png.jpg' --- picard/ui/coverartbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/picard/ui/coverartbox.py b/picard/ui/coverartbox.py index 8f21a7f10..1bffbe9a3 100644 --- a/picard/ui/coverartbox.py +++ b/picard/ui/coverartbox.py @@ -151,7 +151,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) From 23c8271fbc7efd6b809f5c3a0d60f023d2575f3e Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Fri, 11 Jan 2013 18:10:55 +0100 Subject: [PATCH 12/37] CoverArtBox: preserve cover art aspect ratio on drawing Cover is now placed correctly over the cover shadow, aspect ratio is preserved. --- picard/ui/coverartbox.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/picard/ui/coverartbox.py b/picard/ui/coverartbox.py index 1bffbe9a3..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) From 63b17cf2afef6caf0fe0535e2b0e797445d0ba65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lalinsk=C3=BD?= Date: Mon, 14 Jan 2013 22:06:38 +0100 Subject: [PATCH 13/37] Handle error responses from AcoustID http://tickets.musicbrainz.org/browse/PICARD-391 --- picard/acoustid.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/picard/acoustid.py b/picard/acoustid.py index b2f81b824..51cf1a8fb 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): From 63c3af23b847aaea3df2735735be4809f5513013 Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Fri, 18 Jan 2013 01:54:32 +0100 Subject: [PATCH 14/37] InfoDialog: add method to hide tabs --- picard/ui/infodialog.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/picard/ui/infodialog.py b/picard/ui/infodialog.py index 074c33c65..752d0950b 100644 --- a/picard/ui/infodialog.py +++ b/picard/ui/infodialog.py @@ -77,3 +77,8 @@ class InfoDialog(QtGui.QDialog): 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) From a189a753a027a8c50b5cbdb75d53ca0a572ae541 Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Fri, 18 Jan 2013 01:58:07 +0100 Subject: [PATCH 15/37] InfoDialog: split display of each tab in separated methods --- picard/ui/infodialog.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/picard/ui/infodialog.py b/picard/ui/infodialog.py index 752d0950b..09c1d00e7 100644 --- a/picard/ui/infodialog.py +++ b/picard/ui/infodialog.py @@ -33,9 +33,13 @@ class InfoDialog(QtGui.QDialog): self.ui.buttonBox.accepted.connect(self.accept) self.ui.buttonBox.rejected.connect(self.reject) self.setWindowTitle(_("Info") + " - " + file.base_filename) - self.load_info() + self._display_tabs() - def load_info(self): + def _display_tabs(self): + self._display_info_tab() + self._display_artwork_tab() + + def _display_info_tab(self): file = self.file info = [] info.append((_('Filename:'), file.filename)) @@ -69,7 +73,9 @@ class InfoDialog(QtGui.QDialog): text = '
'.join(map(lambda i: '%s
%s' % i, info)) self.ui.info.setText(text) - for image in file.metadata.images: + def _display_artwork_tab(self): + images = self.file.metadata.images + for image in images: data = image["data"] item = QtGui.QListWidgetItem() pixmap = QtGui.QPixmap() From f0d90574e4ede9867463e45f3ac1a708164f3ef4 Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Fri, 18 Jan 2013 01:58:30 +0100 Subject: [PATCH 16/37] InfoDialog: hide artwork tab if empty --- picard/ui/infodialog.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/picard/ui/infodialog.py b/picard/ui/infodialog.py index 09c1d00e7..b45777692 100644 --- a/picard/ui/infodialog.py +++ b/picard/ui/infodialog.py @@ -74,7 +74,12 @@ class InfoDialog(QtGui.QDialog): self.ui.info.setText(text) def _display_artwork_tab(self): + tab = self.ui.artwork_tab images = self.file.metadata.images + if not images: + self.tab_hide(tab) + return + for image in images: data = image["data"] item = QtGui.QListWidgetItem() From 12f817c689a16a9f7fcfa944210ccd1b30eae1d7 Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Fri, 18 Jan 2013 02:14:39 +0100 Subject: [PATCH 17/37] Move file item specific code to its own class FileInfoDialog --- picard/ui/infodialog.py | 62 +++++++++++++++++++++++------------------ picard/ui/mainwindow.py | 4 +-- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/picard/ui/infodialog.py b/picard/ui/infodialog.py index b45777692..1ea747abf 100644 --- a/picard/ui/infodialog.py +++ b/picard/ui/infodialog.py @@ -22,17 +22,15 @@ from PyQt4 import QtGui from picard.util import format_time, encode_filename 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.setWindowTitle(_("Info")) self._display_tabs() def _display_tabs(self): @@ -40,7 +38,38 @@ class InfoDialog(QtGui.QDialog): self._display_artwork_tab() def _display_info_tab(self): - file = self.file + tab = self.ui.info_tab + self.tab_hide(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: @@ -72,24 +101,3 @@ class InfoDialog(QtGui.QDialog): info.append((_('Channels:'), ch)) text = '
'.join(map(lambda i: '%s
%s' % i, info)) self.ui.info.setText(text) - - def _display_artwork_tab(self): - tab = self.ui.artwork_tab - images = self.file.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) diff --git a/picard/ui/mainwindow.py b/picard/ui/mainwindow.py index 5f457122c..dedbc1d7f 100644 --- a/picard/ui/mainwindow.py +++ b/picard/ui/mainwindow.py @@ -32,7 +32,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 from picard.ui.passworddialog import PasswordDialog from picard.util import icontheme, webbrowser2, find_existing_path from picard.util.cdrom import get_cdrom_drives @@ -677,7 +677,7 @@ been merged with that of single artist albums."""), def view_info(self): file = self.tagger.get_files_from_objects(self.selected_objects)[0] - dialog = InfoDialog(file, self) + dialog = FileInfoDialog(file, self) dialog.exec_() def cluster(self): From de521c31a28bfc9ec2d86671424bca1a64cf69cc Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Fri, 18 Jan 2013 02:25:09 +0100 Subject: [PATCH 18/37] Enable Informations dialog for albums, used to display metadata images. It is using existing InfoDialog code, moved to a common class. For now, first tab is just hidden as there is nothing to display in. In Artwork tab, all metadata images associated with the album are shown. Slight change in behavior is that pressing Informations button while an album is selected was showing 1st track information, now it should album info. Info dialog menu item is only available if album has images to display. --- picard/album.py | 3 +++ picard/ui/infodialog.py | 7 +++++++ picard/ui/itemviews.py | 2 ++ picard/ui/mainwindow.py | 11 ++++++++--- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/picard/album.py b/picard/album.py index 13d5b9054..85f7e06df 100644 --- a/picard/album.py +++ b/picard/album.py @@ -388,6 +388,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/ui/infodialog.py b/picard/ui/infodialog.py index 1ea747abf..8c6ce0ab5 100644 --- a/picard/ui/infodialog.py +++ b/picard/ui/infodialog.py @@ -101,3 +101,10 @@ class FileInfoDialog(InfoDialog): info.append((_('Channels:'), ch)) text = '
'.join(map(lambda i: '%s
%s' % i, info)) self.ui.info.setText(text) + + +class AlbumInfoDialog(InfoDialog): + + def __init__(self, album, parent=None): + InfoDialog.__init__(self, album, parent) + self.setWindowTitle(_("Album Info")) diff --git a/picard/ui/itemviews.py b/picard/ui/itemviews.py index 7acb3291b..a5c2e962e 100644 --- a/picard/ui/itemviews.py +++ b/picard/ui/itemviews.py @@ -272,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) diff --git a/picard/ui/mainwindow.py b/picard/ui/mainwindow.py index dedbc1d7f..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 FileInfoDialog +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 = FileInfoDialog(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): From a22a16adf52a58702cd5138533a1a32e5fbae986 Mon Sep 17 00:00:00 2001 From: Michael Wiencek Date: Fri, 18 Jan 2013 20:39:42 -0600 Subject: [PATCH 19/37] Don't show other people's public collections --- picard/album.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/picard/album.py b/picard/album.py index 13d5b9054..f106bba57 100644 --- a/picard/album.py +++ b/picard/album.py @@ -121,10 +121,11 @@ class Album(DataObject, Item): # Add album to collections if "collection_list" in release_node.children: for node in release_node.collection_list[0].collection: - 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) + 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: From 73877099fe1b1be68c78867dbb3d044723100870 Mon Sep 17 00:00:00 2001 From: Michael Wiencek Date: Sat, 19 Jan 2013 15:32:44 -0600 Subject: [PATCH 20/37] small stylistic changes --- picard/ui/infodialog.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/picard/ui/infodialog.py b/picard/ui/infodialog.py index 8c6ce0ab5..7289911a4 100644 --- a/picard/ui/infodialog.py +++ b/picard/ui/infodialog.py @@ -22,7 +22,9 @@ from PyQt4 import QtGui from picard.util import format_time, encode_filename from picard.ui.ui_infodialog import Ui_InfoDialog + class InfoDialog(QtGui.QDialog): + def __init__(self, obj, parent=None): QtGui.QDialog.__init__(self, parent) self.obj = obj @@ -37,10 +39,6 @@ class InfoDialog(QtGui.QDialog): self._display_info_tab() self._display_artwork_tab() - def _display_info_tab(self): - tab = self.ui.info_tab - self.tab_hide(tab) - def _display_artwork_tab(self): tab = self.ui.artwork_tab images = self.obj.metadata.images @@ -62,6 +60,7 @@ class InfoDialog(QtGui.QDialog): index = tab.indexOf(widget) tab.removeTab(index) + class FileInfoDialog(InfoDialog): def __init__(self, file, parent=None): @@ -108,3 +107,7 @@ 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) From 3bfe27ee4155bb21799ac846150d3366a5300b37 Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Sun, 20 Jan 2013 00:03:45 +0100 Subject: [PATCH 21/37] Implement QNetworkAccessManager-level caching --- picard/webservice.py | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/picard/webservice.py b/picard/webservice.py index b0dee45bf..362f5bd6d 100644 --- a/picard/webservice.py +++ b/picard/webservice.py @@ -26,8 +26,10 @@ 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 @@ -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): @@ -385,8 +409,10 @@ class XmlWebService(QtCore.QObject): 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'] From b8e1cc6dd717b3be0502240841dce8746fd049aa Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Mon, 21 Jan 2013 16:09:17 +0100 Subject: [PATCH 22/37] Handle _translate() case for new pyuic versions The code in setup.py replaces QtGui.QApplication.translate() with _(), but it does not handle _translate(), this patch is fixing that. --- setup.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 3ff8c7aae..b540ece4e 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,8 @@ 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()) + for r in list(_translate_re): + source = r.sub(r'_(\1)', tmp.getvalue()) f = open(pyfile, "w") f.write(source) f.close() From c7c852caaaf22bc7d4088a8542a0da21ce89a2db Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Mon, 21 Jan 2013 16:27:47 +0100 Subject: [PATCH 23/37] Set source outside the loop. --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b540ece4e..77b0aa9d3 100755 --- a/setup.py +++ b/setup.py @@ -265,8 +265,9 @@ class picard_build_ui(Command): log.info("compiling %s -> %s", uifile, pyfile) tmp = StringIO() uic.compileUi(uifile, tmp) + source = tmp.getvalue() for r in list(_translate_re): - source = r.sub(r'_(\1)', tmp.getvalue()) + source = r.sub(r'_(\1)', source) f = open(pyfile, "w") f.write(source) f.close() From 0f0c3b0bdb72ad56d97e776fbd198297cc2e1cda Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Tue, 22 Jan 2013 23:02:12 +0100 Subject: [PATCH 24/37] Really enable lookup metadata action for cluster list. When selecting non-empty cluster list, Lookup action was possible (button enabled, action called), but no real action was taking place. This patch enables it. --- picard/cluster.py | 5 +++++ picard/tagger.py | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/picard/cluster.py b/picard/cluster.py index 3ccfe729b..cb836f317 100644 --- a/picard/cluster.py +++ b/picard/cluster.py @@ -278,6 +278,11 @@ class ClusterList(list, Item): def can_browser_lookup(self): return False + def lookup_metadata(self): + for cluster in self: + if not cluster.lookup_task: + cluster.lookup_metadata() + class ClusterDict(object): diff --git a/picard/tagger.py b/picard/tagger.py index 5efe2dafa..7194ab6cd 100644 --- a/picard/tagger.py +++ b/picard/tagger.py @@ -560,8 +560,10 @@ class Tagger(QtGui.QApplication): def autotag(self, objects): for obj in objects: - if isinstance(obj, (File, Cluster)) and not obj.lookup_task: - obj.lookup_metadata() + if (isinstance(obj, ClusterList) + or (isinstance(obj, (File, Cluster)) + and not obj.lookup_task)): + obj.lookup_metadata() # ======================================================================= # Clusters From 6f367c5bdc70f65996304174f12068f7e7ccffde Mon Sep 17 00:00:00 2001 From: Michael Wiencek Date: Wed, 23 Jan 2013 21:52:53 -0600 Subject: [PATCH 25/37] Avoid painful use of isinstance --- picard/cluster.py | 7 ++++--- picard/file.py | 4 +++- picard/tagger.py | 6 ++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/picard/cluster.py b/picard/cluster.py index cb836f317..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'], @@ -280,8 +282,7 @@ class ClusterList(list, Item): def lookup_metadata(self): for cluster in self: - if not cluster.lookup_task: - cluster.lookup_metadata() + cluster.lookup_metadata() class ClusterDict(object): diff --git a/picard/file.py b/picard/file.py index bd10d2161..1d7fd8e26 100644 --- a/picard/file.py +++ b/picard/file.py @@ -550,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/tagger.py b/picard/tagger.py index 7194ab6cd..3fe2e7932 100644 --- a/picard/tagger.py +++ b/picard/tagger.py @@ -560,10 +560,8 @@ class Tagger(QtGui.QApplication): def autotag(self, objects): for obj in objects: - if (isinstance(obj, ClusterList) - or (isinstance(obj, (File, Cluster)) - and not obj.lookup_task)): - obj.lookup_metadata() + if obj.can_autotag(): + obj.lookup_metadata() # ======================================================================= # Clusters From b6c158b23f37f6c05d95b68df3011a83bd828400 Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Mon, 28 Jan 2013 15:09:27 +0100 Subject: [PATCH 26/37] Request CAA only if MB indicates release has caa artwork Since 2013/01/28, web service is indicating if release has artwork at cover art archive, use this info to prevent useless requests. Related to http://tickets.musicbrainz.org/browse/MBS-4536 --- picard/coverart.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/picard/coverart.py b/picard/coverart.py index f43d0b814..2f9530e2b 100644 --- a/picard/coverart.py +++ b/picard/coverart.py @@ -166,7 +166,16 @@ 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 + if 'cover_art_archive' in release.children: + node = release.children['cover_art_archive'][0] + if 'artwork' in node.children: + has_caa_artwork = bool(node.artwork[0].text == 'true') + + if QObject.config.setting['ca_provider_use_caa'] and has_caa_artwork: album._requests += 1 album.tagger.xmlws.download( "coverartarchive.org", 80, "/release/%s/" % From 4ec111e293f8629c0bd89f87eb0addab7bd54ac0 Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Mon, 28 Jan 2013 20:47:24 +0100 Subject: [PATCH 27/37] CAA types selection: replace free text input with a list with checkboxes Free text input was simple, but it can't be translated, and isn't very user-friendly. This patch introduces a CAA types selector, with translatable titles and descriptions. Upgrade is seamless since the whole list is still saved/load as a simple string using space as separator, with english lowercased names of each type. Define missing tabstops for Cover Art options UI Regenerate picard/ui/ui_options_cover.py with pyuic 4.9.6 --- picard/coverartarchive.py | 36 +++++++++++++++++++++++++ picard/ui/options/cover.py | 47 ++++++++++++++++++++++++++++++-- picard/ui/ui_options_cover.py | 50 ++++++++++++++++++++++++++-------- ui/options_cover.ui | 51 ++++++++++++++++++++++++++++++----- 4 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 picard/coverartarchive.py 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/ui/options/cover.py b/picard/ui/options/cover.py index 036b9a3d7..32965a5c6 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 name, typ in list((i['name'], i) for i in CAA_TYPES): + enabled = name in self._enabled_types + self._add_item(typ, 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/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 From 17ae61d575483511d6b40a2392b576181e6ad92e Mon Sep 17 00:00:00 2001 From: Michael Wiencek Date: Tue, 29 Jan 2013 15:48:57 -0600 Subject: [PATCH 28/37] Tiny loop simplification --- picard/ui/options/cover.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/picard/ui/options/cover.py b/picard/ui/options/cover.py index 32965a5c6..bc4a07953 100644 --- a/picard/ui/options/cover.py +++ b/picard/ui/options/cover.py @@ -32,9 +32,9 @@ class CAATypesSelector(object): self._populate() def _populate(self): - for name, typ in list((i['name'], i) for i in CAA_TYPES): - enabled = name in self._enabled_types - self._add_item(typ, enabled=enabled) + 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) From 915231470acb389204a389f06fc5d427834c2ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lalinsk=C3=BD?= Date: Thu, 31 Jan 2013 11:49:01 +0100 Subject: [PATCH 29/37] Replace Windows-incompatible characters as the last step (fixes PICARD-393) --- picard/file.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/picard/file.py b/picard/file.py index 1d7fd8e26..ae5c52b9d 100644 --- a/picard/file.py +++ b/picard/file.py @@ -249,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 From e30d2a15c6f9486106dc5d5e7f738e10c34b2312 Mon Sep 17 00:00:00 2001 From: Wieland Hoffmann Date: Sun, 3 Feb 2013 18:03:12 +0100 Subject: [PATCH 30/37] Simplify the cover-art-archive element handling --- picard/coverart.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/picard/coverart.py b/picard/coverart.py index 2f9530e2b..d5a06005e 100644 --- a/picard/coverart.py +++ b/picard/coverart.py @@ -171,11 +171,12 @@ def coverart(album, metadata, release, try_list=None): # http://tickets.musicbrainz.org/browse/MBS-4536 has_caa_artwork = False if 'cover_art_archive' in release.children: - node = release.children['cover_art_archive'][0] - if 'artwork' in node.children: - has_caa_artwork = bool(node.artwork[0].text == 'true') + has_caa_artwork = bool(release.children['cover_art_archive'][0] + .artwork[0].text == 'true') if QObject.config.setting['ca_provider_use_caa'] and has_caa_artwork: + QObject.log.debug("There are images in the cover art archive for %s" + % release.id) album._requests += 1 album.tagger.xmlws.download( "coverartarchive.org", 80, "/release/%s/" % @@ -183,6 +184,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 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) From 0870df0a8b4eaa3de792568846027e0fd0399586 Mon Sep 17 00:00:00 2001 From: Wieland Hoffmann Date: Sun, 3 Feb 2013 19:42:57 +0100 Subject: [PATCH 31/37] Use CAA information when only 2 or 1 image types are enabled. --- picard/coverart.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/picard/coverart.py b/picard/coverart.py index d5a06005e..a7701bb21 100644 --- a/picard/coverart.py +++ b/picard/coverart.py @@ -170,12 +170,34 @@ def coverart(album, metadata, release, try_list=None): # MB web service indicates if CAA has artwork # http://tickets.musicbrainz.org/browse/MBS-4536 has_caa_artwork = False + caa_node = release.children['cover_art_archive'][0] if 'cover_art_archive' in release.children: - has_caa_artwork = bool(release.children['cover_art_archive'][0] - .artwork[0].text == 'true') + has_caa_artwork = bool(caa_node.artwork[0].text == 'true') + + caa_types = map(unicode.lower, + QObject.config.setting["caa_image_types"].split()) + + 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: - QObject.log.debug("There are images in the cover art archive for %s" + QObject.log.debug("There are suitable images in the cover art archive for %s" % release.id) album._requests += 1 album.tagger.xmlws.download( @@ -184,7 +206,7 @@ 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 images in the cover art archive for %s" + 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) From 760ae6dba216954d804d7ca4e6daddad7940154b Mon Sep 17 00:00:00 2001 From: Wieland Hoffmann Date: Sun, 3 Feb 2013 19:46:39 +0100 Subject: [PATCH 32/37] Don't download the CAA index.json when no types are enabled --- picard/coverart.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/picard/coverart.py b/picard/coverart.py index a7701bb21..43f86b3d7 100644 --- a/picard/coverart.py +++ b/picard/coverart.py @@ -171,12 +171,12 @@ def coverart(album, metadata, release, try_list=None): # http://tickets.musicbrainz.org/browse/MBS-4536 has_caa_artwork = False caa_node = release.children['cover_art_archive'][0] + caa_types = map(unicode.lower, + QObject.config.setting["caa_image_types"].split()) + if 'cover_art_archive' in release.children: has_caa_artwork = bool(caa_node.artwork[0].text == 'true') - caa_types = map(unicode.lower, - QObject.config.setting["caa_image_types"].split()) - 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 @@ -196,7 +196,8 @@ def coverart(album, metadata, release, try_list=None): 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: + 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 From 61966484462d78ab9658197bea206b34c9a258d9 Mon Sep 17 00:00:00 2001 From: Wieland Hoffmann Date: Mon, 4 Feb 2013 15:54:19 +0100 Subject: [PATCH 33/37] Only create caa_node if it exits in the xml --- picard/coverart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/picard/coverart.py b/picard/coverart.py index 43f86b3d7..6ca896650 100644 --- a/picard/coverart.py +++ b/picard/coverart.py @@ -170,11 +170,11 @@ def coverart(album, metadata, release, try_list=None): # MB web service indicates if CAA has artwork # http://tickets.musicbrainz.org/browse/MBS-4536 has_caa_artwork = False - caa_node = release.children['cover_art_archive'][0] 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): From 15dbbc9e2ea648ca4d8016dd3eb6706b8b7edb16 Mon Sep 17 00:00:00 2001 From: Wieland Hoffmann Date: Mon, 4 Feb 2013 16:37:15 +0100 Subject: [PATCH 34/37] Don't enable "other versions" if more than one album is selected --- picard/ui/itemviews.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/picard/ui/itemviews.py b/picard/ui/itemviews.py index a5c2e962e..b7a3a211b 100644 --- a/picard/ui/itemviews.py +++ b/picard/ui/itemviews.py @@ -292,17 +292,21 @@ class BaseTreeView(QtGui.QTreeWidget): 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): From d083fce8fd00d8986a285787beacc58a863ed1a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lalinsk=C3=BD?= Date: Thu, 7 Feb 2013 21:25:58 +0100 Subject: [PATCH 35/37] Use only the duration from fpcalc (for consistency) --- picard/acoustid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/picard/acoustid.py b/picard/acoustid.py index 51cf1a8fb..f38766fca 100644 --- a/picard/acoustid.py +++ b/picard/acoustid.py @@ -129,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 From 71cc8b037fdec494fd54ee41848b1751b0004c20 Mon Sep 17 00:00:00 2001 From: Michael Wiencek Date: Fri, 8 Feb 2013 23:43:46 -0600 Subject: [PATCH 36/37] Add %_releasegroup% and %_releasegroupcomment% For access to the name and disambiguation, respectively. --- picard/mbxml.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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) From eae878223831cdc3d373a200e4d3db771bde0186 Mon Sep 17 00:00:00 2001 From: Michael Wiencek Date: Sun, 17 Feb 2013 14:29:19 -0600 Subject: [PATCH 37/37] PICARD-399: clicking an album should always expand it caused by de521c --- picard/ui/itemviews.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/picard/ui/itemviews.py b/picard/ui/itemviews.py index b7a3a211b..057fcbe1b 100644 --- a/picard/ui/itemviews.py +++ b/picard/ui/itemviews.py @@ -470,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):