diff --git a/picard/album.py b/picard/album.py index b07ba7917..6fda7bbed 100644 --- a/picard/album.py +++ b/picard/album.py @@ -18,6 +18,7 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +import traceback from PyQt4 import QtCore from musicbrainz2.model import Relation from musicbrainz2.utils import extractUuid, extractFragment @@ -27,32 +28,90 @@ from picard.dataobj import DataObject from picard.track import Track from picard.script import ScriptParser from picard.ui.item import Item -from picard.util import needs_read_lock, needs_write_lock, format_time - - -_AMAZON_IMAGE_URL = "http://images.amazon.com/images/P/%s.01.LZZZZZZZ.jpg" - - -class AlbumLoadError(Exception): - pass +from picard.util import format_time +from picard.cluster import Cluster +from picard.mbxml import release_to_metadata, track_to_metadata class Album(DataObject, Item): - def __init__(self, id, title=None): + def __init__(self, id): DataObject.__init__(self, id) self.metadata = Metadata() - if title: - self.metadata[u"album"] = title - self.unmatched_files = [] - self.files = [] + self.unmatched_files = Cluster(_("Unmatched Files"), special=2) self.tracks = [] self.loaded = False + self._files = 0 + self._requests = 0 def __str__(self): return '' % (self.id, self.metadata[u"album"]) + def _parse_release(self, document): + """Make album object from a parsed XML document.""" + m = self._new_metadata + + release_node = document.metadata[0].release[0] + release_to_metadata(release_node, m) + run_album_metadata_processors(self, m, release_node) + + for i, node in enumerate(release_node.track_list[0].track): + t = Track(node.attribs['id'], self) + self._new_tracks.append(t) + tm = t.metadata + tm.copy(m) + track_to_metadata(node, tm) + tm['tracknumber'] = str(i + 1) + run_track_metadata_processors(self, m, release_node, node) + + def _release_request_finished(self, document, http, error): + try: + if error: + self.log.error(unicode(http.errorString())) + else: + try: + self._parse_release(document) + except: + error = True + self.log.error(traceback.format_exc()) + finally: + self._requests -= 1 + self._finalize_loading(error) + + def _finalize_loading(self, error): + if error: + self.metadata.clear() + self.metadata['album'] = _("[couldn't load album %s]") % self.id + del self._new_metadata + del self._new_tracks + self.update() + else: + if not self._requests: + self.metadata = self._new_metadata + self.tracks = self._new_tracks + del self._new_metadata + del self._new_tracks + self.loaded = True + self.update() + self.match_files(self.unmatched_files.files) + def load(self, force=False): + if self._requests: + self.log.info("Not reloading, some requests are still active.") + return + self.loaded = False + self.metadata.clear() + self.metadata['album'] = _("[loading album information]") + self.update() + self._new_metadata = Metadata() + self._new_tracks = [] + self._requests = 1 + self.tagger.xmlws.get_release_by_id(self.id, self._release_request_finished, inc=('tracks','artist')) + + def update(self, update_tracks=True): + self.tagger.emit(QtCore.SIGNAL("album_updated"), self, update_tracks) + + def load_(self, force=False): self.tagger.window.set_statusbar_message('Loading release %s...', self.id) ws = self.tagger.get_web_service(cached=not force) @@ -125,82 +184,13 @@ class Album(DataObject, Item): self.metadata["~#length"] = duration self.metadata["~length"] = format_time(duration) - @needs_read_lock - def getNumTracks(self): - return len(self.tracks) + def _add_file(self, track, file): + self._files += 1 + self.update(False) - @needs_write_lock - def addUnmatchedFile(self, file): - self.unmatched_files.append(file) - self.emit(QtCore.SIGNAL("fileAdded(int)"), file.id) - - @needs_write_lock - def addLinkedFile(self, track, file): - index = self.tracks.index(track) - self.files.append(file) - self.emit(QtCore.SIGNAL("track_updated"), track) - - @needs_write_lock - def removeLinkedFile(self, track, file): - self.emit(QtCore.SIGNAL("track_updated"), track) - - @needs_read_lock - def getNumUnmatchedFiles(self): - return len(self.unmatched_files) - - @needs_read_lock - def getNumTracks(self): - return len(self.tracks) - - @needs_read_lock - def getNumLinkedFiles(self): - count = 0 - for track in self.tracks: - if track.is_linked(): - count += 1 - return count - - @needs_write_lock - def remove_file(self, file): - index = self.unmatched_files.index(file) - self.emit(QtCore.SIGNAL("fileAboutToBeRemoved"), index) -# self.test = self.unmatched_files[index] - del self.unmatched_files[index] - print self.unmatched_files - self.emit(QtCore.SIGNAL("fileRemoved"), index) - - def matchFile(self, file): - bestMatch = 0.0, None - for track in self.tracks: - sim = file.orig_metadata.compare(track.metadata) - if sim > bestMatch[0]: - bestMatch = sim, track - - if bestMatch[1]: - file.move_to_track(bestMatch[1]) - - @needs_read_lock - def can_save(self): - """Return if this object can be saved.""" - if self.files: - return True - else: - return False - - def can_remove(self): - """Return if this object can be removed.""" - return True - - def can_edit_tags(self): - """Return if this object supports tag editing.""" - return False - - def can_analyze(self): - """Return if this object can be fingerprinted.""" - return False - - def can_refresh(self): - return True + def _remove_file(self, track, file): + self._files -= 1 + self.update(False) def match_files(self, files): """Match files on tracks on this album, based on metadata similarity.""" @@ -219,8 +209,11 @@ class Album(DataObject, Item): if track.linked_file and sim < track.linked_file.similarity: continue matched[file] = track + unmatched = [f for f in files if f not in matched] for file, track in matched.items(): file.move(track) + for file in unmatched: + file.move(self.unmatched_files) def match_file(self, file, trackid=None): """Match the file on a track on this album, based on trackid or metadata similarity.""" @@ -231,10 +224,25 @@ class Album(DataObject, Item): return self.match_files([file]) + def can_save(self): + return self._files > 0 + + def can_remove(self): + return True + + def can_edit_tags(self): + return False + + def can_analyze(self): + return False + + def can_refresh(self): + return True + def column(self, column): if column == 'title': - if self.getNumTracks(): - return '%s (%d/%d)' % (self.metadata['album'], self.getNumTracks(), self.getNumLinkedFiles()) + if self.tracks: + return '%s (%d/%d)' % (self.metadata['album'], len(self.tracks), self._files) else: return self.metadata['album'] elif column == '~length': diff --git a/picard/cluster.py b/picard/cluster.py index 043a04cea..a21956356 100644 --- a/picard/cluster.py +++ b/picard/cluster.py @@ -43,6 +43,9 @@ class Cluster(QtCore.QObject, Item): def __repr__(self): return '' % self.metadata['album'] + def __len__(self): + return len(self.files) + def add_file(self, file): self.metadata['totaltracks'] += 1 self.metadata['~#length'] += file.metadata['~#length'] diff --git a/picard/mbxml.py b/picard/mbxml.py new file mode 100644 index 000000000..3f2fd5157 --- /dev/null +++ b/picard/mbxml.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# Copyright (C) 2007 Lukáš Lalinský +# +# 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 picard.util import format_time + + +def artist_to_metadata(node, m, release=False): + m['musicbrainz_artistid'] = node.attribs['id'] + if release: + m['musicbrainz_albumartistid'] = m['musicbrainz_artistid'] + for name, nodes in node.children.iteritems(): + if not nodes: + continue + if name == 'name': + m['artist'] = nodes[0].text + if release: + m['albumartist'] = m['artist'] + elif name == 'sort_name': + m['artistsort'] = nodes[0].text + if release: + m['albumartistsort'] = m['artistsort'] + + +def track_to_metadata(node, m): + m['musicbrainz_trackid'] = node.attribs['id'] + for name, nodes in node.children.iteritems(): + if not nodes: + continue + if name == 'title': + m['title'] = nodes[0].text + elif name == 'duration': + m['~#length'] = int(nodes[0].text) + elif name == 'artist': + artist_to_metadata(nodes[0], m) + if '~#length' not in m: + m['~#length'] = 0 + m['~length'] = format_time(m['~#length']) + + +def release_to_metadata(node, m): + m['musicbrainz_albumid'] = node.attribs['id'] + for name, nodes in node.children.iteritems(): + if not nodes: + continue + if name == 'title': + m['album'] = nodes[0].text + elif name == 'asin': + m['asin'] = nodes[0].text + elif name == 'artist': + artist_to_metadata(nodes[0], m, True) diff --git a/picard/tagger.py b/picard/tagger.py index 7f146c329..7f3909b26 100644 --- a/picard/tagger.py +++ b/picard/tagger.py @@ -74,6 +74,7 @@ from picard.util import ( from picard.util.cachedws import CachedWebService from picard.util.search import LuceneQueryFilter from picard.util.thread import ThreadAssist +from picard.webservice import XmlWebService from musicbrainz2.disc import readDisc, getSubmissionUrl, DiscError from musicbrainz2.utils import extractUuid @@ -102,7 +103,7 @@ class Tagger(QtGui.QApplication): - cluster_removed(album, index) - album_added(album) - - album_updated(album) + - album_updated(album, update_tracks) - album_removed(album, index) - track_updated(track) @@ -140,6 +141,8 @@ class Tagger(QtGui.QApplication): self.thread_assist = ThreadAssist(self) self.load_thread = self.thread_assist.allocate() + self.xmlws = XmlWebService(self.cachedir) + # Initialize fingerprinting self._ofa = musicdns.OFA() self._analyze_thread = self.thread_assist.allocate() @@ -152,8 +155,6 @@ class Tagger(QtGui.QApplication): self.puidmanager = PUIDManager() - self.__files_to_be_moved = [] - self.browser_integration = BrowserIntegration() self.files = {} @@ -239,8 +240,8 @@ class Tagger(QtGui.QApplication): if album.loaded: album.match_files(files) else: - for file in files: - self.__files_to_be_moved.append((file, album)) + for file in [file for file in files]: + file.move(album.unmatched_files) def move_file_to_album(self, file, albumid=None, album=None): """Move `file` to a track on album `albumid`.""" @@ -252,7 +253,7 @@ class Tagger(QtGui.QApplication): if album.loaded: album.match_file(file, trackid) else: - self.__files_to_be_moved.append((file, album, trackid)) + file.move(album.unmatched_files) def exit(self): self.stopping = True @@ -486,34 +487,19 @@ class Tagger(QtGui.QApplication): self.remove_files(files) def load_album(self, id): - """Load an album specified by MusicBrainz ID.""" album = self.get_album_by_id(id) if album: return album - album = Album(id, _("[loading album information]")) + album = Album(id) self.albums.append(album) self.emit(QtCore.SIGNAL("album_added"), album) - self.thread_assist.spawn(self.__load_album_thread, album) + album.load() return album def reload_album(self, album): - album.name = _("[loading album information]") - self.emit(QtCore.SIGNAL("album_updated"), album) - self.thread_assist.spawn(self.__load_album_thread, album, True) + album.load(force=True) - def __load_album_thread(self, album, force=False): - try: - album.load(force) - except Exception, e: - self.log.error(traceback.format_exc()) - self.window.set_statusbar_message('Loading release failed: %s', e, timeout=3000) - self.thread_assist.proxy_to_main(self.__load_album_failed, album) - else: - self.thread_assist.proxy_to_main(self.__load_album_finished, album) - - def __load_album_finished(self, album): - self.emit(QtCore.SIGNAL("album_updated"), album) - album.loaded = True + def finalize_album_loading(self, album): for item in self.__files_to_be_moved: if item[1] == album: if len(item) == 3: @@ -521,10 +507,6 @@ class Tagger(QtGui.QApplication): else: item[1].match_file(item[0]) - def __load_album_failed(self, album): - album.metadata['album'] = _("[couldn't load release %s]") % album.id - self.emit(QtCore.SIGNAL("album_updated"), album) - def get_album_by_id(self, id): for album in self.albums: if album.id == id: diff --git a/picard/track.py b/picard/track.py index 567e20af6..7374aba2a 100644 --- a/picard/track.py +++ b/picard/track.py @@ -33,31 +33,33 @@ class Track(DataObject): self.metadata = Metadata() def __str__(self): - return '' % (self.id, self.metadata[u"title"]) + return '' % (self.id, self.metadata["title"]) def add_file(self, file): if self.linked_file: - self.linked_file.move(self.tagger.unmatched_files) + self.linked_file.move(self.album.unmatched_files) self.linked_file = file file.saved_metadata.copy(file.metadata) file.metadata.copy(self.metadata) if 'musicip_puid' in file.saved_metadata: file.metadata['musicip_puid'] = file.saved_metadata['musicip_puid'] file.metadata.changed = True - self.album.addLinkedFile(self, file) + self.album._add_file(self, file) file.update(signal=False) - self.tagger.emit(QtCore.SIGNAL("track_updated"), self) + self.update() def remove_file(self, file): file = self.linked_file file.metadata.copy(file.saved_metadata) self.linked_file = None - self.album.removeLinkedFile(self, file) - self.tagger.emit(QtCore.SIGNAL("track_updated"), self) + self.album._remove_file(self, file) + self.update() return file def update_file(self, file): - assert file == self.linked_file + self.update() + + def update(self): self.tagger.emit(QtCore.SIGNAL("track_updated"), self) def is_linked(self): diff --git a/picard/ui/itemviews.py b/picard/ui/itemviews.py index a83c73589..462d99a86 100644 --- a/picard/ui/itemviews.py +++ b/picard/ui/itemviews.py @@ -109,7 +109,7 @@ class MainPanel(QtGui.QSplitter): def unregister_object(self, obj=None, item=None): if obj is None and item is not None: - obj = self.item_from_object(item) + obj = self.object_from_item(item) if obj is not None and item is None: item = self.item_from_object(obj) del self._object_to_item[obj] @@ -147,12 +147,16 @@ class MainPanel(QtGui.QSplitter): self.register_object(file, item) self.update_file(file, item) self.update_cluster(cluster, cluster_item) + if cluster.special == 2 and cluster.files: + cluster_item.setHidden(False) def remove_file_from_cluster(self, cluster, file, index): cluster_item = self.item_from_object(cluster) cluster_item.takeChild(index) self.unregister_object(file) self.update_cluster(cluster, cluster_item) + if cluster.special == 2 and not cluster.files: + cluster_item.setHidden(True) class BaseTreeView(QtGui.QTreeWidget): @@ -317,6 +321,20 @@ class BaseTreeView(QtGui.QTreeWidget): if obj.can_edit_tags(): self.window.edit_tags(obj) + def add_cluster(self, cluster, parent_item=None): + if parent_item is None: + parent_item = self.clusters + cluster_item = QtGui.QTreeWidgetItem(parent_item) + cluster_item.setIcon(0, self.panel.icon_dir) + self.panel.update_cluster(cluster, cluster_item) + self.panel.register_object(cluster, cluster_item) + for file in cluster.files: + item = QtGui.QTreeWidgetItem(cluster_item) + self.panel.register_object(file, item) + self.panel.update_file(file, item) + if cluster.special == 2 and not cluster.files: + cluster_item.setHidden(True) + class FileTreeView(BaseTreeView): @@ -335,16 +353,6 @@ class FileTreeView(BaseTreeView): self.connect(self.tagger, QtCore.SIGNAL("cluster_added"), self.add_cluster) self.connect(self.tagger, QtCore.SIGNAL("cluster_removed"), self.remove_cluster) - def add_cluster(self, cluster): - cluster_item = QtGui.QTreeWidgetItem(self.clusters) - cluster_item.setIcon(0, self.panel.icon_dir) - self.panel.update_cluster(cluster, cluster_item) - self.panel.register_object(cluster, cluster_item) - for file in cluster.files: - item = QtGui.QTreeWidgetItem(cluster_item) - self.panel.register_object(file, item) - self.update_file(file, item) - def remove_cluster(self, cluster, index): for file in cluster.files: self.panel.unregister_object(file) @@ -406,6 +414,7 @@ class AlbumTreeView(BaseTreeView): font.setBold(True) item.setFont(i, font) item.setText(i, album.column(column[1])) + self.add_cluster(album.unmatched_files, item) def update_album(self, album, update_tracks=True): album_item = self.panel.item_from_object(album) @@ -419,6 +428,7 @@ class AlbumTreeView(BaseTreeView): item = QtGui.QTreeWidgetItem(album_item) self.panel.register_object(track, item) self.update_track(track, item) + self.add_cluster(album.unmatched_files, album_item) def remove_album(self, album, index): self.panel.unregister_object(album) diff --git a/picard/webservice.py b/picard/webservice.py new file mode 100644 index 000000000..61e681bb6 --- /dev/null +++ b/picard/webservice.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# Copyright (C) 2007 Lukáš Lalinský +# +# 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. + + +""" +Asynchronous XML web service. +""" + +import re +import sha +from PyQt4 import QtCore, QtNetwork, QtXml +from picard import version_string + + +def _node_name(name): + return re.sub('[^a-zA-Z0-9]', '_', unicode(name)) + + +class XmlNode(object): + + def __init__(self): + self.text = u'' + self.children = {} + self.attribs = {} + + def __repr__(self): + return repr(self.__dict__) + + def __getattr__(self, name): + try: + return self.children[name] + except KeyError: + try: + return self.attribs[name] + except KeyError: + raise AttributeError, name + + +class XmlHandler(QtXml.QXmlDefaultHandler): + + def init(self): + self.document = XmlNode() + self.node = self.document + self.path = [] + + def startElement(self, namespace, name, qname, attrs): + node = XmlNode() + for i in xrange(attrs.count()): + node.attribs[_node_name(attrs.localName(i))] = unicode(attrs.value(i)) + self.node.children.setdefault(_node_name(name), []).append(node) + self.path.append(self.node) + self.node = node + return True + + def endElement(self, namespace, name, qname): + self.node = self.path.pop() + return True + + def characters(self, text): + self.node.text += unicode(text) + return True + + +class XmlWebService(QtNetwork.QHttp): + + def __init__(self, cachedir, parent=None): + QtNetwork.QHttp.__init__(self, parent) + self.connect(self, QtCore.SIGNAL("requestStarted(int)"), self._start_request) + self.connect(self, QtCore.SIGNAL("requestFinished(int, bool)"), self._finish_request) + self.connect(self, QtCore.SIGNAL("readyRead(const QHttpResponseHeader &)"), self._read_data) + self._cachedir = cachedir + self._request_handlers = {} + self._xml_handler = XmlHandler() + self._xml_reader = QtXml.QXmlSimpleReader() + self._xml_reader.setContentHandler(self._xml_handler) + self._xml_input = QtXml.QXmlInputSource() + self._using_proxy = False + + def _make_cache_filename(self, host, port, path): + url = "%s:%d%s" % (host, port, path) + filename = sha.new(url).hexdigest() + m = re.search(r"\.([a-z]{2,3})(?:\?|$)", url) + if m: + filename += "." + m.group(1) + return os.path.join(self._cachedir, filename) + + def _start_request(self, request_id): + if request_id in self._request_handlers: + self._xml_handler.init() + self._new_request = True + + def _finish_request(self, request_id, error): + try: + handler = self._request_handlers[request_id] + except KeyError: + pass + else: + if handler is not None: + handler(self._xml_handler.document, self, error) + del self._request_handlers[request_id] + + def _read_data(self, response): + if response.statusCode() != 200: + self.abort() + else: + self._xml_input.setData(self.readAll()) + if self._new_request: + ok = self._xml_reader.parse(self._xml_input, True) + self._new_request = False + else: + ok = self._xml_reader.parseContinue() + if not ok: + self.abort() + + def _prepare(self, method, host, port, path): + self.log.debug("%s http://%s:%d%s", method, host, port, path) + header = QtNetwork.QHttpRequestHeader(method, path) + if port == 80: + header.setValue("Host", "%s" % host) + else: + header.setValue("Host", "%s:%d" % (host, port)) + header.setValue("User-Agent", "MusicBrainz Picard/%s" % version_string) + if method == "POST": + header.setContentType("application/x-www-form-urlencoded") + if self.config.setting["use_proxy"]: + self.setProxy(self.config.setting["proxy_server_host"], self.config.setting["proxy_server_port"], self.config.setting["proxy_username"], self.config.setting["proxy_password"]) + self._using_proxy = True + elif self._using_proxy: + self.setProxy(QtCore.QString(), QtCore.QString()) + self._using_proxy = False + self.setHost(host, port) + return header + + def get(self, host, port, path, handler): + header = self._prepare("GET", host, port, path) + requestid = self.request(header) + self._request_handlers[requestid] = handler + + def post(self, host, port, path, data, handler): + header = self._prepare("POST", host, port, path) + requestid = self.request(header, data) + self._request_handlers[requestid] = handler + + def get_release_by_id(self, releaseid, handler, inc=[]): + host = self.config.setting["server_host"] + port = self.config.setting["server_port"] + path = "/ws/1/release/%s?type=xml&inc=%s" % (releaseid, "+".join(inc)) + self.get(host, port, path, handler)