Thread-less album loading.

This commit is contained in:
Lukáš Lalinský
2007-02-15 15:12:29 +01:00
parent 8a37e5f937
commit 18d6ae5285
7 changed files with 372 additions and 137 deletions

View File

@@ -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 '<Album %s %r>' % (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':

View File

@@ -43,6 +43,9 @@ class Cluster(QtCore.QObject, Item):
def __repr__(self):
return '<Cluster %r>' % 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']

66
picard/mbxml.py Normal file
View File

@@ -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)

View File

@@ -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:

View File

@@ -33,31 +33,33 @@ class Track(DataObject):
self.metadata = Metadata()
def __str__(self):
return '<Track %s %r>' % (self.id, self.metadata[u"title"])
return '<Track %s %r>' % (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):

View File

@@ -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)

164
picard/webservice.py Normal file
View File

@@ -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)