Melodia/Melodia/resources/audiotools/__freedb__.py

725 lines
24 KiB
Python

#!/usr/bin/python
#Audio Tools, a module and set of tools for manipulating audio data
#Copyright (C) 2007-2011 Brian Langenberger
#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 audiotools import (VERSION, Con, cStringIO, sys, re, MetaData,
AlbumMetaData, AlbumMetaDataFile, __most_numerous__,
DummyAudioFile, MetaDataFileException)
import StringIO
import gettext
gettext.install("audiotools", unicode=True)
#######################
#XMCD
#######################
class XMCDException(MetaDataFileException):
"""Raised if some error occurs parsing an XMCD file."""
def __unicode__(self):
return _(u"Invalid XMCD file")
class XMCD(AlbumMetaDataFile):
LINE_LENGTH = 78
def __init__(self, fields, comments):
"""fields a dict of key->values. comment is a list of comments.
keys are plain strings. values and comments are unicode."""
self.fields = fields
self.comments = comments
def __getattr__(self, key):
if (key == 'album_name'):
dtitle = self.fields.get('DTITLE', u"")
if (u" / " in dtitle):
return dtitle.split(u" / ", 1)[1]
else:
return dtitle
elif (key == 'artist_name'):
dtitle = self.fields.get('DTITLE', u"")
if (u" / " in dtitle):
return dtitle.split(u" / ", 1)[0]
else:
return u""
elif (key == 'year'):
return self.fields.get('DYEAR', u"")
elif (key == 'catalog'):
return u""
elif (key == 'extra'):
return self.fields.get('EXTD', u"")
else:
try:
return self.__dict__[key]
except KeyError:
raise AttributeError(key)
def __setattr__(self, key, value):
if (key == 'album_name'):
dtitle = self.fields.get('DTITLE', u"")
if (u" / " in dtitle):
artist = dtitle.split(u" / ", 1)[0]
self.fields['DTITLE'] = u"%s / %s" % (artist, value)
else:
self.fields['DTITLE'] = value
elif (key == 'artist_name'):
dtitle = self.fields.get('DTITLE', u"")
if (u" / " in dtitle):
album = dtitle.split(u" / ", 1)[1]
else:
album = dtitle
self.fields['DTITLE'] = u"%s / %s" % (value, album)
elif (key == 'year'):
self.fields['DYEAR'] = value
elif (key == 'catalog'):
pass
elif (key == 'extra'):
self.fields['EXTD'] = value
else:
self.__dict__[key] = value
def __len__(self):
track_field = re.compile(r'(TTITLE|EXTT)(\d+)')
return max(set([int(m.group(2)) for m in
[track_field.match(key) for key in self.fields.keys()]
if m is not None])) + 1
def to_string(self):
def write_field(f, key, value):
chars = list(value)
encoded_value = "%s=" % (key)
while ((len(chars) > 0) and
(len(encoded_value +
chars[0].encode('utf-8', 'replace')) <
XMCD.LINE_LENGTH)):
encoded_value += chars.pop(0).encode('utf-8', 'replace')
f.write("%s\r\n" % (encoded_value))
if (len(chars) > 0):
write_field(f, key, u"".join(chars))
output = cStringIO.StringIO()
for comment in self.comments:
output.write(comment.encode('utf-8'))
output.write('\r\n')
fields = set(self.fields.keys())
for field in ['DISCID', 'DTITLE', 'DYEAR', 'DGENRE']:
if (field in fields):
write_field(output, field, self.fields[field])
fields.remove(field)
for i in xrange(len(self)):
field = 'TTITLE%d' % (i)
if (field in fields):
write_field(output, field, self.fields[field])
fields.remove(field)
if ('EXTD' in fields):
write_field(output, 'EXTD', self.fields['EXTD'])
fields.remove('EXTD')
for i in xrange(len(self)):
field = 'EXTT%d' % (i)
if (field in fields):
write_field(output, field, self.fields[field])
fields.remove(field)
for field in fields:
write_field(output, field, self.fields[field])
return output.getvalue()
@classmethod
def from_string(cls, string):
# try:
# data = string.decode('latin-1')
# except UnicodeDecodeError:
# data = string.decode('utf-8','replace')
#FIXME - handle latin-1 files?
data = string.decode('utf-8', 'replace')
if (not data.startswith(u"# xmcd")):
raise XMCDException()
fields = {}
comments = []
field_line = re.compile(r'([A-Z0-9]+?)=(.*)')
for line in StringIO.StringIO(data):
if (line.startswith(u'#')):
comments.append(line.rstrip('\r\n'))
else:
match = field_line.match(line.rstrip('\r\n'))
if (match is not None):
key = match.group(1).encode('ascii')
value = match.group(2)
if (key in fields):
fields[key] += value
else:
fields[key] = value
return cls(fields, comments)
def get_track(self, index):
try:
ttitle = self.fields['TTITLE%d' % (index)]
track_extra = self.fields['EXTT%d' % (index)]
except KeyError:
return (u"", u"", u"")
if (u' / ' in ttitle):
(track_artist, track_title) = ttitle.split(u' / ', 1)
else:
track_title = ttitle
track_artist = u""
return (track_title, track_artist, track_extra)
def set_track(self, index, name, artist, extra):
if ((index < 0) or (index >= len(self))):
raise IndexError(index)
if (len(artist) > 0):
self.fields["TTITLE%d" % (index)] = u"%s / %s" % (artist, name)
else:
self.fields["TTITLE%d" % (index)] = name
if (len(extra) > 0):
self.fields["EXTT%d" % (index)] = extra
@classmethod
def from_tracks(cls, tracks):
def track_string(track, album_artist, metadata):
if (track.track_number() in metadata.keys()):
metadata = metadata[track.track_number()]
if (metadata.artist_name == album_artist):
return metadata.track_name
else:
return u"%s / %s" % (metadata.artist_name,
metadata.track_name)
else:
return u""
audiofiles = [f for f in tracks if f.track_number() != 0]
audiofiles.sort(lambda t1, t2: cmp(t1.track_number(),
t2.track_number()))
discid = DiscID([track.cd_frames() for track in audiofiles])
metadata = dict([(t.track_number(), t.get_metadata())
for t in audiofiles
if (t.get_metadata() is not None)])
artist_names = [m.artist_name for m in metadata.values()]
if (len(artist_names) == 0):
album_artist = u""
elif ((len(artist_names) > 1) and
(len(set(artist_names)) == len(artist_names))):
#if all track artists are different, don't pick one
album_artist = u"Various"
else:
album_artist = __most_numerous__(artist_names)
return cls(dict([("DISCID", str(discid).decode('ascii')),
("DTITLE", u"%s / %s" % \
(album_artist,
__most_numerous__([m.album_name for m in
metadata.values()]))),
("DYEAR", __most_numerous__([m.year for m in
metadata.values()])),
("EXTDD", u""),
("PLAYORDER", u"")] + \
[("TTITLE%d" % (track.track_number() - 1),
track_string(track, album_artist, metadata))
for track in audiofiles] + \
[("EXTT%d" % (track.track_number() - 1),
u"")
for track in audiofiles]),
[u"# xmcd",
u"#",
u"# Track frame offsets:"] +
[u"#\t%d" % (offset) for offset in discid.offsets()] +
[u"#",
u"# Disc length: %d seconds" % (
(discid.length() / 75) + 2),
u"#"])
#######################
#FREEDB
#######################
class DiscID:
"""An object representing a 32 bit FreeDB disc ID value."""
DISCID = Con.Struct('discid',
Con.UBInt8('digit_sum'),
Con.UBInt16('length'),
Con.UBInt8('track_count'))
def __init__(self, tracks=[], offsets=None, length=None, lead_in=150):
"""Fields are as follows:
tracks - a list of track lengths in CD frames
offsets - a list of track offsets in CD frames
length - the length of the entire disc in CD frames
lead_in - the location of the first track on the CD, in frames
These fields are all optional.
One will presumably fill them with data later in that event.
"""
self.tracks = tracks
self.__offsets__ = offsets
self.__length__ = length
self.__lead_in__ = lead_in
@classmethod
def from_cdda(cls, cdda):
"""Given a CDDA object, returns a populated DiscID.
May raise ValueError if there are no audio tracks on the CD."""
tracks = list(cdda)
if (len(tracks) < 1):
raise ValueError(_(u"no audio tracks in CDDA object"))
return cls(tracks=[t.length() for t in tracks],
offsets=[t.offset() for t in tracks],
length=cdda.last_sector(),
lead_in=tracks[0].offset())
def add(self, track):
"""Adds a new track length, in CD frames."""
self.tracks.append(track)
def offsets(self):
"""Returns a list of calculated offset integers, from track lengths."""
if (self.__offsets__ is None):
offsets = [self.__lead_in__]
for track in self.tracks[0:-1]:
offsets.append(track + offsets[-1])
return offsets
else:
return self.__offsets__
def length(self):
"""Returns the total length of the disc, in seconds."""
if (self.__length__ is None):
return sum(self.tracks)
else:
return self.__length__
def idsuffix(self):
"""Returns a FreeDB disc ID suffix string.
This is for making server queries."""
return str(len(self.tracks)) + " " + \
" ".join([str(offset) for offset in self.offsets()]) + \
" " + str((self.length() + self.__lead_in__) / 75)
def __str__(self):
def __count_digits__(i):
if (i == 0):
return 0
else:
return (i % 10) + __count_digits__(i / 10)
disc_id = Con.Container()
disc_id.track_count = len(self.tracks)
disc_id.length = self.length() / 75
disc_id.digit_sum = sum([__count_digits__(o / 75)
for o in self.offsets()]) % 0xFF
return DiscID.DISCID.build(disc_id).encode('hex')
def freedb_id(self):
"""Returns the entire FreeDB disc ID, including suffix."""
return str(self) + " " + self.idsuffix()
def toxmcd(self, output):
"""Writes a newly created XMCD file to output.
Its values are populated from this DiscID's fields."""
output.write(XMCD.from_tracks(
[DummyAudioFile(length, None, i + 1)
for (i, length) in enumerate(self.tracks)]).to_string())
class FreeDBException(Exception):
"""Raised if some problem occurs during FreeDB querying."""
pass
class FreeDB:
"""A class for performing queries on a FreeDB or compatible server.
This operates using the original FreeDB client-server protocol."""
LINE = re.compile(r'\d\d\d\s.+')
def __init__(self, server, port, messenger):
"""server is a string, port is an int, messenger is a Messenger.
Queries are sent to the server, and output to the messenger."""
self.server = server
self.port = port
self.socket = None
self.r = None
self.w = None
self.messenger = messenger
def connect(self):
"""Performs the initial connection."""
import socket
try:
self.messenger.info(_(u"Connecting to \"%s\"") % (self.server))
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.connect((self.server, self.port))
self.r = self.socket.makefile("rb")
self.w = self.socket.makefile("wb")
(code, msg) = self.read() # the welcome message
if (code == 201):
self.messenger.info(_(u"Connected ... attempting to login"))
else:
self.r.close()
self.w.close()
self.socket.close()
raise FreeDBException(_(u"Invalid hello message"))
self.write("cddb hello user %s %s %s" % \
(socket.getfqdn(), "audiotools", VERSION))
(code, msg) = self.read() # the handshake successful message
if (code != 200):
self.r.close()
self.w.close()
self.socket.close()
raise FreeDBException(_(u"Handshake unsuccessful"))
self.write("proto 6")
(code, msg) = self.read() # the protocol successful message
if ((code != 200) and (code != 201)):
self.r.close()
self.w.close()
self.socket.close()
raise FreeDBException(_(u"Protocol change unsuccessful"))
except socket.error, err:
raise FreeDBException(err[1])
def close(self):
"""Closes an open connection."""
self.messenger.info(_(u"Closing connection"))
self.write("quit")
(code, msg) = self.read() # the quit successful message
self.r.close()
self.w.close()
self.socket.close()
def write(self, line):
"""Writes a single command line to the server."""
if (self.socket is not None):
self.w.write(line)
self.w.write("\r\n")
self.w.flush()
def read(self):
"""Reads a result line from the server."""
line = self.r.readline()
if (FreeDB.LINE.match(line)):
return (int(line[0:3]), line[4:].rstrip("\r\n"))
else:
return (None, line.rstrip("\r\n"))
def query(self, disc_id):
"""Given a DiscID, performs an album query and returns matches.
Each match is a (category, id) pair, which the user may
need to decide between."""
matches = []
self.messenger.info(
_(u"Sending Disc ID \"%(disc_id)s\" to server \"%(server)s\"") % \
{"disc_id": str(disc_id).decode('ascii'),
"server": self.server.decode('ascii', 'replace')})
self.write("cddb query " + disc_id.freedb_id())
(code, msg) = self.read()
if (code == 200):
matches.append(msg)
elif ((code == 211) or (code == 210)):
while (msg != "."):
(code, msg) = self.read()
if (msg != "."):
matches.append(msg)
if (len(matches) == 1):
self.messenger.info(_(u"1 match found"))
else:
self.messenger.info(_(u"%s matches found") % (len(matches)))
return map(lambda m: m.split(" ", 2), matches)
def read_data(self, category, id, output):
"""Reads the FreeDB entry matching category and id to output.
category and id are raw strings, as returned by query().
output is an open file object.
"""
self.write("cddb read " + category + " " + id)
(code, msg) = self.read()
if (code == 210):
line = self.r.readline()
while (line.strip() != "."):
output.write(line)
line = self.r.readline()
else:
print >> sys.stderr, (code, msg)
class FreeDBWeb(FreeDB):
"""A class for performing queries on a FreeDB or compatible server.
This operates using the FreeDB web-based protocol."""
def __init__(self, server, port, messenger):
"""server is a string, port is an int, messenger is a Messenger.
Queries are sent to the server, and output to the messenger."""
self.server = server
self.port = port
self.connection = None
self.messenger = messenger
def connect(self):
"""Performs the initial connection."""
import httplib
self.connection = httplib.HTTPConnection(self.server, self.port,
timeout=10)
def close(self):
"""Closes an open connection."""
if (self.connection is not None):
self.connection.close()
def write(self, line):
"""Writes a single command line to the server."""
import urllib
import socket
u = urllib.urlencode({"hello": "user %s %s %s" % \
(socket.getfqdn(),
"audiotools",
VERSION),
"proto": str(6),
"cmd": line})
try:
self.connection.request(
"POST",
"/~cddb/cddb.cgi",
u,
{"Content-type": "application/x-www-form-urlencoded",
"Accept": "text/plain"})
except socket.error, msg:
raise FreeDBException(str(msg))
def read(self):
"""Reads a result line from the server."""
response = self.connection.getresponse()
return response.read()
def __parse_line__(self, line):
if (FreeDB.LINE.match(line)):
return (int(line[0:3]), line[4:].rstrip("\r\n"))
else:
return (None, line.rstrip("\r\n"))
def query(self, disc_id):
"""Given a DiscID, performs an album query and returns matches.
Each match is a (category, id) pair, which the user may
need to decide between."""
matches = []
self.messenger.info(
_(u"Sending Disc ID \"%(disc_id)s\" to server \"%(server)s\"") % \
{"disc_id": str(disc_id).decode('ascii'),
"server": self.server.decode('ascii', 'replace')})
self.write("cddb query " + disc_id.freedb_id())
data = cStringIO.StringIO(self.read())
(code, msg) = self.__parse_line__(data.readline())
if (code == 200):
matches.append(msg)
elif ((code == 211) or (code == 210)):
while (msg != "."):
(code, msg) = self.__parse_line__(data.readline())
if (msg != "."):
matches.append(msg)
if (len(matches) == 1):
self.messenger.info(_(u"1 match found"))
else:
self.messenger.info(_(u"%s matches found") % (len(matches)))
return map(lambda m: m.split(" ", 2), matches)
def read_data(self, category, id, output):
"""Reads the FreeDB entry matching category and id to output.
category and id are raw strings, as returned by query().
output is an open file object.
"""
self.write("cddb read " + category + " " + id)
data = cStringIO.StringIO(self.read())
(code, msg) = self.__parse_line__(data.readline())
if (code == 210):
line = data.readline()
while (line.strip() != "."):
output.write(line)
line = data.readline()
else:
print >> sys.stderr, (code, msg)
#matches is a list of (category,disc_id,title) tuples returned from
#FreeDB.query(). If the length of that list is 1, return the first
#item. If the length is greater than one, present the user a list of
#choices and force him/her to pick the closest match for the CD.
#That data can then be sent to FreeDB.read_data()
def __select_match__(matches, messenger):
if (len(matches) == 1):
return matches[0]
elif (len(matches) < 1):
return None
else:
messenger.info(_(u"Please Select the Closest Match:"))
selected = 0
while ((selected < 1) or (selected > len(matches))):
for i in range(len(matches)):
messenger.info(_(u"%(choice)s) [%(genre)s] %(name)s") % \
{"choice": i + 1,
"genre": matches[i][0],
"name": matches[i][2].decode('utf-8',
'replace')})
try:
messenger.partial_info(_(u"Your Selection [1-%s]:") % \
(len(matches)))
selected = int(sys.stdin.readline().strip())
except ValueError:
selected = 0
return matches[selected - 1]
def __select_default_match__(matches, selection):
if (len(matches) < 1):
return None
else:
try:
return matches[selection]
except IndexError:
return matches[0]
def get_xmcd(disc_id, output, freedb_server, freedb_server_port,
messenger, default_selection=None):
"""Runs through the entire FreeDB querying sequence.
Fields are as follows:
disc_id - a DiscID object
output - an open file object for writing
freedb_server - a server name string
freedb_port - a server port int
messenger - a Messenger object
default_selection - if given, the default match to choose
"""
try:
freedb = FreeDBWeb(freedb_server, freedb_server_port, messenger)
freedb.connect()
except FreeDBException, msg:
#if an exception occurs during the opening,
#freedb will auto-close its sockets
raise IOError(str(msg))
try:
matches = freedb.query(disc_id)
#HANDLE MULTIPLE MATCHES, or NO MATCHES
if (len(matches) > 0):
if (default_selection is None):
(category, idstring, title) = __select_match__(
matches, messenger)
else:
(category, idstring, title) = __select_default_match__(
matches, default_selection)
freedb.read_data(category, idstring, output)
output.flush()
freedb.close()
except FreeDBException, msg:
#otherwise, close the sockets manually
freedb.close()
raise IOError(str(msg))
return len(matches)