From 669aed0f4625712a0331ea38a10d3a52615a53e2 Mon Sep 17 00:00:00 2001 From: John-Mark Gurney Date: Sun, 5 Dec 2010 10:54:51 -0800 Subject: [PATCH] improve the DIDLLite to match ConectentDirectory v2.. Most of the optional items are available at the Object level now... so we can make these a lot more generic... not so much specific code anymore.. (though some clients, such as MediaControler, will only use these elements if ContentDirectory v1 listed them, such as originalTrackNumber on a MusicTrack)... add cdrtoc.py that parses toc files that include title and other information.. add an attribute to flac that has the tags... This makes it more generic... use the new toc parser and the tags element to set the title on tracks... also set the original track number.. [git-p4: depot-paths = "//depot/": change = 1577] --- DIDLLite.py | 230 +++++++++++++++++++++++----------------------------- audioraw.py | 51 ++++++++---- cdrtoc.py | 134 ++++++++++++++++++++++++++++++ flac.py | 3 + 4 files changed, 276 insertions(+), 142 deletions(-) create mode 100644 cdrtoc.py diff --git a/DIDLLite.py b/DIDLLite.py index 5fcdbb7..1ef13ec 100644 --- a/DIDLLite.py +++ b/DIDLLite.py @@ -109,9 +109,79 @@ class Object(object): """The root class of the entire content directory class heirachy.""" klass = 'object' - creator = None + + _optionattrs = { + 'creator': 'dc', + 'writeStatus': 'upnp', + 'artist': 'upnp', + 'actor': 'upnp', + 'author': 'upnp', + 'producer': 'upnp', + 'director': 'upnp', + 'publisher': 'dc', + 'contributor': 'dc', + 'genre': 'upnp', + 'album': 'upnp', + 'playlist': 'upnp', + 'albumArtURI': 'upnp', + 'artistDiscographyURI': 'upnp', + 'lyricsURI': 'upnp', + 'relation': 'dc', + 'storageMedium': 'upnp', + 'description': 'dc', + 'longDescription': 'upnp', + 'icon': 'upnp', + 'region': 'upnp', + 'rights': 'dc', + 'date': 'dc', + 'language': 'dc', + 'playbackCount': 'upnp', + 'lastPlaybackTime': 'upnp', + 'lastPlaybackPosition': 'upnp', + 'recordedStartDateTime': 'upnp', + 'recordedDuration': 'upnp', + 'recordedDayOfWeek': 'upnp', + 'srsRecordScheduleID': 'upnp', + 'srsRecordTaskID': 'upnp', + 'recordable': 'upnp', + 'programTitle': 'upnp', + 'seriesTitle': 'upnp', + 'programID': 'upnp', + 'seriesID': 'upnp', + 'channelID': 'upnp', + 'episodeCount': 'upnp', + 'episodeNumber': 'upnp', + 'programCode': 'upnp', + 'rating': 'upnp', + 'channelGroupName': 'upnp', + 'callSign': 'upnp', + 'networkAffiliation': 'upnp', + 'serviceProvider': 'upnp', + 'price': 'upnp', + 'payPerView': 'upnp', + 'epgProviderName': 'upnp', + 'dateTimeRange': 'upnp', + 'radioCallSign': 'upnp', + 'radioStationID': 'upnp', + 'radioBand': 'upnp', + 'channelNr': 'upnp', + 'channelName': 'upnp', + 'scheduledStartTime': 'upnp', + 'scheduledEndTime': 'upnp', + 'signalStrength': 'upnp', + 'signalLocked': 'upnp', + 'tuned': 'upnp', + 'neverPlayable': 'upnp', + 'bookmarkID': 'upnp', + 'bookmarkedObjectID': 'upnp', + 'deviceUDN': 'upnp', + 'stateVariableCollection': 'upnp', + 'DVDRegionCode': 'upnp', + 'originalTrackNumber': 'upnp', + 'toc': 'upnp', + 'userAnnoation': 'upnp', + } res = None - writeStatus = None content = property(lambda x: x._content) needupdate = None # do we update before sending? (for res) @@ -130,7 +200,12 @@ class Object(object): self.restricted = '0' if kwargs.has_key('content'): - self._content = kwargs['content'] + self._content = kwargs.pop('content') + + for i in kwargs: + if i not in self._optionattrs: + raise TypeError('invalid keyword arg: %s' % `i`) + setattr(self, i, kwargs[i]) def __cmp__(self, other): if not isinstance(other, self.__class__): @@ -160,8 +235,12 @@ class Object(object): root.attrib['restricted'] = self.restricted - if self.creator is not None: - SubElement(root, 'dc:creator').text = self.creator + for i in (x for x in self.__dict__ if x in self._optionattrs): + obj = getattr(self, i) + if obj is None: + continue + SubElement(root, '%s:%s' % (self._optionattrs[i], + i)).text = unicode(getattr(self, i)) if self.res is not None: try: @@ -171,9 +250,6 @@ class Object(object): for res in resiter: root.append(res.toElement()) - if self.writeStatus is not None: - SubElement(root, 'upnp:writeStatus').text = self.writeStatus - return root def toString(self): @@ -213,83 +289,11 @@ class AudioItem(Item): klass = Item.klass + '.audioItem' - genre = None - description = None - longDescription = None - publisher = None - language = None - relation = None - rights = None - - def toElement(self): - - root = Item.toElement(self) - - if self.genre is not None: - SubElement(root, 'upnp:genre').text = self.genre - - if self.description is not None: - SubElement(root, 'dc:description').text = self.description - - if self.longDescription is not None: - SubElement(root, 'upnp:longDescription').text = \ - self.longDescription - - if self.publisher is not None: - SubElement(root, 'dc:publisher').text = self.publisher - - if self.language is not None: - SubElement(root, 'dc:language').text = self.language - - if self.relation is not None: - SubElement(root, 'dc:relation').text = self.relation - - if self.rights is not None: - SubElement(root, 'dc:rights').text = self.rights - - return root - class MusicTrack(AudioItem): """A discrete piece of audio that should be interpreted as music.""" klass = AudioItem.klass + '.musicTrack' - artist = None - album = None - originalTrackNumber = None - playlist = None - storageMedium = None - contributor = None - date = None - - def toElement(self): - - root = AudioItem.toElement(self) - - if self.artist is not None: - SubElement(root, 'upnp:artist').text = self.artist - - if self.album is not None: - SubElement(root, 'upnp:album').text = self.album - - if self.originalTrackNumber is not None: - SubElement(root, 'upnp:originalTrackNumber').text = \ - self.originalTrackNumber - - if self.playlist is not None: - SubElement(root, 'upnp:playlist').text = self.playlist - - if self.storageMedium is not None: - SubElement(root, 'upnp:storageMedium').text = self.storageMedium - - if self.contributor is not None: - SubElement(root, 'dc:contributor').text = self.contributor - - if self.date is not None: - SubElement(root, 'dc:date').text = self.date - - return root - class AudioBroadcast(AudioItem): klass = AudioItem.klass + '.audioBroadcast' @@ -321,12 +325,19 @@ class Container(Object, list): elementName = 'container' childCount = property(lambda x: len(x)) - createClass = None - searchClass = None searchable = None updateID = 0 needupdate = False + _optionattrs = Object._optionattrs.copy() + _optionattrs.update({ + 'searchClass': 'upnp', + 'createClass': 'upnp', + 'storageTotal': 'upnp', + 'storageUsed': 'upnp', + 'storageFree': 'upnp', + 'storageMaxPartition': 'upnp', + }) def __init__(self, cd, id, parentID, title, **kwargs): Object.__init__(self, cd, id, parentID, title, **kwargs) list.__init__(self) @@ -444,9 +455,6 @@ class Container(Object, list): if self.childCount: root.attrib['childCount'] = str(self.childCount) - self._addSet('upnp:createclass', self.createClass) - self._addSet('upnp:searchclass', self.searchClass) - if self.searchable is not None: root.attrib['searchable'] = str(self.searchable) @@ -488,56 +496,24 @@ class MovieGenre(Genre): class StorageSystem(Container): klass = Container.klass + '.storageSystem' - total = -1 - used = -1 - free = -1 - maxpartition = -1 - medium = 'UNKNOWN' - - def toElement(self): - - root = Container.toElement(self) - - SubElement(root, 'upnp:storageTotal').text = str(self.total) - SubElement(root, 'upnp:storageUsed').text = str(self.used) - SubElement(root, 'upnp:storageFree').text = str(self.free) - SubElement(root, 'upnp:storageMaxPartition').text = str(self.maxpartition) - SubElement(root, 'upnp:storageMedium').text = self.medium - - return root + storageTotal = -1 + storageUsed = -1 + storageFree = -1 + storageMaxParition = -1 + storageMedium = 'UNKNOWN' class StorageVolume(Container): klass = Container.klass + '.storageVolume' - total = -1 - used = -1 - free = -1 - medium = 'UNKNOWN' - - def toElement(self): - - root = Container.toElement(self) - - SubElement(root, 'upnp:storageTotal').text = str(self.total) - SubElement(root, 'upnp:storageUsed').text = str(self.used) - SubElement(root, 'upnp:storageFree').text = str(self.free) - SubElement(root, 'upnp:storageMedium').text = self.medium - - return root + storageTotal = -1 + storageUsed = -1 + storageFree = -1 + storageMedium = 'UNKNOWN' class StorageFolder(Container): klass = Container.klass + '.storageFolder' - used = -1 - - def toElement(self): - - root = Container.toElement(self) - - if self.used is not None: - SubElement(root, 'upnp:storageUsed').text = str(self.used) - - return root + storageUsed = -1 class DIDLElement(_ElementInterface): def __init__(self): diff --git a/audioraw.py b/audioraw.py index 0ce76fd..ecd9e19 100644 --- a/audioraw.py +++ b/audioraw.py @@ -3,7 +3,9 @@ '''Audio Raw Converter''' -from DIDLLite import AudioItem, Album, Resource, ResourceList +import cdrtoc + +from DIDLLite import MusicTrack, AudioItem, MusicAlbum, Resource, ResourceList from FSStorage import FSObject, registerklassfun from twisted.web import resource, server @@ -148,26 +150,26 @@ class AudioResource(resource.Resource): # and make sure the connection doesn't get closed return server.NOT_DONE_YET -# XXX - maybe should be MusicAlbum, but needs to change AudioRaw -class AudioDisc(FSObject, Album): +class AudioDisc(FSObject, MusicAlbum): def __init__(self, *args, **kwargs): self.cuesheet = kwargs.pop('cuesheet') + self.toc = kwargs.pop('toc', {}) self.kwargs = kwargs.copy() self.file = kwargs.pop('file') - nchan = kwargs['channels'] - samprate = kwargs['samplerate'] - bitsps = kwargs['bitspersample'] - samples = kwargs['samples'] + nchan = kwargs.pop('channels') + samprate = kwargs.pop('samplerate') + bitsps = kwargs.pop('bitspersample') + samples = kwargs.pop('samples') totalbytes = nchan * samples * bitsps / 8 - FSObject.__init__(self, kwargs['path']) + FSObject.__init__(self, kwargs.pop('path')) # XXX - exclude track 1 pre-gap? kwargs['content'] = AudioResource(self.file, - kwargs.pop('decoder'), 0, kwargs['samples']) + kwargs.pop('decoder'), 0, samples) #print 'doing construction' - Album.__init__(self, *args, **kwargs) + MusicAlbum.__init__(self, *args, **kwargs) #print 'adding resource' self.url = '%s/%s' % (self.cd.urlbase, self.id) @@ -183,7 +185,7 @@ class AudioDisc(FSObject, Album): self.res.append(r) #print 'completed' - def sort(self, fun=lambda x, y: cmp(int(x.title), int(y.title))): + def sort(self, fun=lambda x, y: cmp(int(x.originalTrackNumber), int(y.originalTrackNumber))): return list.sort(self, fun) def genChildren(self): @@ -229,10 +231,18 @@ class AudioDisc(FSObject, Album): kwargs['start'] = start kwargs['samples'] = trkarray[trkidx + 1]['offset'] - start #print 'track: %d, kwargs: %s' % (i, `kwargs`) + kwargs['originalTrackNumber'] = i + try: + oi = self.toc['tracks'][i]['TITLE'] + pass + except KeyError: + pass - return AudioRaw, oi, (), kwargs + return AudioRawTrack, oi, (), kwargs -class AudioRaw(AudioItem, FSObject): +# XXX - figure out how to make custom mix-ins w/ other than AudioItem +# metaclass? +class AudioRawBase(FSObject): def __init__(self, *args, **kwargs): file = kwargs.pop('file') nchan = kwargs.pop('channels') @@ -242,12 +252,12 @@ class AudioRaw(AudioItem, FSObject): startsamp = kwargs.pop('start', 0) totalbytes = nchan * samples * bitsps / 8 - FSObject.__init__(self, kwargs['path']) + FSObject.__init__(self, kwargs.pop('path')) #print 'AudioRaw:', `startsamp`, `samples` kwargs['content'] = AudioResource(file, kwargs.pop('decoder'), startsamp, samples) - AudioItem.__init__(self, *args, **kwargs) + self.baseObject.__init__(self, *args, **kwargs) self.url = '%s/%s' % (self.cd.urlbase, self.id) self.res = ResourceList() @@ -261,6 +271,12 @@ class AudioRaw(AudioItem, FSObject): r.nrAudioChannels = nchan self.res.append(r) +class AudioRaw(AudioRawBase, AudioItem): + baseObject = AudioItem + +class AudioRawTrack(AudioRawBase, MusicTrack): + baseObject = MusicTrack + def detectaudioraw(origpath, fobj): for i in decoders.itervalues(): try: @@ -280,6 +296,11 @@ def detectaudioraw(origpath, fobj): } if obj.cuesheet is not None: + print 'tags:', `obj.tags` + if 'jmg_toc' in obj.tags: + args['toc'] = cdrtoc.parsetoc( + obj.tags['jmg_toc'][0]) + args['cuesheet'] = obj.cuesheet return AudioDisc, args diff --git a/cdrtoc.py b/cdrtoc.py new file mode 100644 index 0000000..dccd1de --- /dev/null +++ b/cdrtoc.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python + +import string + +types = frozenset([ 'CD_DA', 'CD_ROM', 'CD_ROMXA']) + +def decodestr(i, pos): + return decodestrend(i, pos)[0] + +def decodestrend(i, pos): + r = [] + bspos = None + dqpos = None + while True: + if bspos is None or bspos == -1 or bspos < pos: + bspos = i.find('\\', pos) + if dqpos is None or dqpos < pos: + dqpos = i.index('"', pos) + if bspos >= 0 and bspos < dqpos: + r.append(i[pos:bspos]) + c = i[bspos + 1] + if c == '"': + r.append('"') + pos = bspos + 2 + elif c in string.digits: + r.append(unichr(int(i[bspos + 1:bspos + 4], 8))) + pos = bspos + 4 + else: + raise ValueError('unknown escape char') + else: + r.append(i[pos:dqpos]) + break + + return ''.join(r), dqpos + +def parsetoc(toc): + # state machine info: + # 0: header + # 1: in CD_TEXT + # 2: in LANGUAGE_MAP + # 3: in LANGUAGE + + r = { 'tracks': {} } + langmap = {} + state = 0 + curlang = None + textobj = None + langobj = None + track = 0 + for i in toc.split('\n'): + i = i.strip() + if not i: + continue + + items = i.split() + key = items[0] + + if state == 0: + if i in types: + r['type'] = i + elif key == 'CD_TEXT': + state = 1 + if track == 0: + textobj = r + elif key == 'TRACK': + track += 1 + textobj = { 'track': track } + r['tracks'][track] = textobj + elif key == 'TWO_CHANNEL_AUDIO': + textobj['channels'] = 2 + elif key == 'FOUR_CHANNEL_AUDIO': + textobj['channels'] = 4 + elif key == 'ISRC': + textobj['isrc'] = decodestr(i, i.index('"') + 1) + elif key == 'COPY': + textobj['copy'] = True + elif items[0] == 'NO' and items[1] == 'COPY': + textobj['copy'] = False + elif key == 'PRE_EMPHASIS': + textobj['preemphasis'] = True + elif items[0] == 'NO' and items[1] == 'PRE_EMPHASIS': + textobj['preemphasis'] = False + elif key == 'FILE': + pass # XXX + elif key == 'START': + pass # XXX + elif key == '//': + pass + else: + raise ValueError('unknown line: %s' % `i`) + elif state == 1: + if key == 'LANGUAGE_MAP': + state = 2 + elif key == 'LANGUAGE': + state = 3 + langobj = textobj + # XXX - don't try to use more than one! + #lang = items[1].strip() + #textobj[langmap[lang]] = langobj + elif key == '}': + textobj = None + state = 0 + elif state == 2: + if key == '}': + state = 1 + else: + key, value = (x.strip() for x in i.split(':')) + value = int(value) + langmap[key] = value + elif state == 3: + if key == '}': + langobj = None + state = 1 + else: + curl = i.find('{') + dquo = i.find('"') + if curl != -1 and curl < dquo: + val = i[i.index('{') + 1:i.index('}')] + val = ''.join(chr(int(x)) for x in + val.split(',')) + else: + if dquo == -1: + raise ValueError('no dquote') + val = decodestr(i, dquo + 1) + langobj[key] = val + + return r + +if __name__ == '__main__': + import sys + + for i in sys.argv[1:]: + print 'file:', `i` + print parsetoc(open(i).read()) diff --git a/flac.py b/flac.py index fa2311c..8cee1b9 100644 --- a/flac.py +++ b/flac.py @@ -469,6 +469,7 @@ class FLACDec(object): bitspersample = property(lambda x: x._bitspersample) totalsamples = property(lambda x: x._totalsamples) bytespersample = property(lambda x: x._bytespersample) + tags = property(lambda x: x._tags) vorbis = property(lambda x: x._vorbis) cuesheet = property(lambda x: x._cuesheet) @@ -508,6 +509,7 @@ class FLACDec(object): raise ValueError( FLAC__StreamDecoderInitStatusString[status]) + self._tags = {} self._vorbis = None self._cuesheet = None @@ -586,6 +588,7 @@ class FLACDec(object): #print `si` elif md.type == FLAC__METADATA_TYPE_VORBIS_COMMENT: self._vorbis = md.data.vorbis_comment.asobj() + self._tags = self.vorbis[1] #print 'vc:', `md.data.vorbis_comment` #print 'v:', `self.vorbis` elif md.type == FLAC__METADATA_TYPE_CUESHEET: