|
|
@@ -0,0 +1,559 @@ |
|
|
|
#!/usr/bin/env python |
|
|
|
# Copyright 2009 John-Mark Gurney <jmg@funkthat.com> |
|
|
|
'''CD Changer Module''' |
|
|
|
|
|
|
|
__version__ = '$Change: 1366 $' |
|
|
|
# $Id: //depot/python/pymeds/main/shoutcast.py#24 $ |
|
|
|
|
|
|
|
import CDDB |
|
|
|
import audio |
|
|
|
import filelock |
|
|
|
import os.path |
|
|
|
import shelve |
|
|
|
import slinke |
|
|
|
|
|
|
|
from DIDLLite import Container, StorageSystem, StorageVolume |
|
|
|
from DIDLLite import MusicGenre, MusicTrack, MusicAlbum, Resource, ResourceList |
|
|
|
from FSStorage import registerklassfun |
|
|
|
|
|
|
|
from twisted.python import log, threadable, failure |
|
|
|
from twisted.internet import defer, protocol, reactor, threads |
|
|
|
from twisted.internet.interfaces import IPushProducer, IPullProducer, IConsumer |
|
|
|
from twisted.internet.serialport import SerialPort |
|
|
|
from twisted.web import error, http, resource, server |
|
|
|
|
|
|
|
from zope.interface import implements |
|
|
|
|
|
|
|
def isint(s): |
|
|
|
try: |
|
|
|
i = int(s) |
|
|
|
return True |
|
|
|
except ValueError: |
|
|
|
return False |
|
|
|
|
|
|
|
class LimitedAudioProducer(object): |
|
|
|
implements(IPushProducer, IPullProducer, IConsumer) |
|
|
|
|
|
|
|
# request, args |
|
|
|
def __init__(self, consumer, *args, **kwargs): |
|
|
|
self.remain = self.setlength(consumer) |
|
|
|
self.consumer = consumer |
|
|
|
audio.AudioPlayer(self, *args, **kwargs) |
|
|
|
|
|
|
|
@staticmethod |
|
|
|
def setlength(request): |
|
|
|
if request.responseHeaders.hasHeader('content-length'): |
|
|
|
secs = int( |
|
|
|
request.responseHeaders.getRawHeaders( |
|
|
|
'content-length')[0].strip()) |
|
|
|
r = secs * audio.bytespersecmt( |
|
|
|
request.responseHeaders.getRawHeaders( |
|
|
|
'content-type')[0]) |
|
|
|
request.responseHeaders.setRawHeaders( |
|
|
|
'content-length', [ str(r) ]) |
|
|
|
else: |
|
|
|
r = None |
|
|
|
|
|
|
|
return r |
|
|
|
|
|
|
|
def shutdown(self): |
|
|
|
print 'lap: shutdown', `self.consumer` |
|
|
|
# XXX - I still get writes after I've asked my producer to stop |
|
|
|
self.write = lambda x: None |
|
|
|
self.producer.stopProducing() |
|
|
|
self.producer = None |
|
|
|
self.consumer.unregisterProducer() |
|
|
|
self.consumer = None |
|
|
|
#self.remain = 0 # No more writes |
|
|
|
|
|
|
|
# IPushProducer |
|
|
|
def pauseProducing(self): |
|
|
|
return self.producer.pauseProducing() |
|
|
|
|
|
|
|
def resumeProducing(self): |
|
|
|
return self.producer.resumeProducing() |
|
|
|
|
|
|
|
def stopProducing(self): |
|
|
|
print 'lap: sp' |
|
|
|
self.shutdown() |
|
|
|
|
|
|
|
# IConsumer |
|
|
|
def registerProducer(self, producer, streaming): |
|
|
|
print 'lap: regp' |
|
|
|
self.producer = producer |
|
|
|
self.streamingProducer = streaming |
|
|
|
|
|
|
|
self.consumer.registerProducer(self, streaming) |
|
|
|
|
|
|
|
def unregisterProducer(): |
|
|
|
print 'lap: unregp' |
|
|
|
self.shutdown() |
|
|
|
|
|
|
|
def write(self, data): |
|
|
|
if self.remain is not None: |
|
|
|
data = data[:self.remain] |
|
|
|
self.remain -= len(data) |
|
|
|
self.consumer.write(data) |
|
|
|
|
|
|
|
if self.remain is not None and not self.remain: |
|
|
|
# Done producing |
|
|
|
self.shutdown() |
|
|
|
|
|
|
|
class LimitedAudioRes(audio.AudioResource): |
|
|
|
producerFactory = LimitedAudioProducer |
|
|
|
|
|
|
|
def render(self, request): |
|
|
|
r = audio.AudioResource.render(self, request) |
|
|
|
|
|
|
|
if request.method == 'HEAD': |
|
|
|
LimitedAudioProducer.setlength(request) |
|
|
|
|
|
|
|
return r |
|
|
|
|
|
|
|
class ChangerTrack(resource.Resource): |
|
|
|
isLeaf = True |
|
|
|
|
|
|
|
def __init__(self, obj, res): |
|
|
|
resource.Resource.__init__(self) |
|
|
|
self._obj = obj |
|
|
|
self._res = res |
|
|
|
|
|
|
|
def getmimetype(self): |
|
|
|
return self._res.getmimetype() |
|
|
|
|
|
|
|
def render(self, request): |
|
|
|
print 'CTrender:', `request.postpath`, `request.method`, `request` |
|
|
|
self.setlength(request) |
|
|
|
|
|
|
|
if request.method == 'HEAD': |
|
|
|
return self._res.render(request) |
|
|
|
|
|
|
|
# Needs to be created here so that it will fire. |
|
|
|
# If not, the list may be walked before we add it. |
|
|
|
nf = request.notifyFinish() |
|
|
|
|
|
|
|
d = self._obj.trylock(20, request) |
|
|
|
d.addCallback(lambda x: self._obj.cue()) |
|
|
|
# XXX - how to stop playback when track is done? |
|
|
|
#d.addCallback(lambda x: self.setlength(request)) |
|
|
|
d.addCallback(lambda x: self.printarg('before render, after cue')) |
|
|
|
|
|
|
|
d.addCallback(lambda x: self._res.render(request)) |
|
|
|
|
|
|
|
d.addCallback(lambda x: self.docleanup(nf, request)) |
|
|
|
|
|
|
|
d.addErrback(lambda exc: self.failed(exc, request)) |
|
|
|
# XXX - add errBack? |
|
|
|
d.addCallback(lambda x: self.printarg('after render, before play')) |
|
|
|
d.addCallback(lambda x: self._obj.play()) |
|
|
|
d.addErrback(self.logerr) |
|
|
|
|
|
|
|
return server.NOT_DONE_YET |
|
|
|
|
|
|
|
def setlength(self, request): |
|
|
|
r = self._obj.getlength() |
|
|
|
request.responseHeaders.setRawHeaders( |
|
|
|
'content-length', [ str(r) ]) |
|
|
|
|
|
|
|
def docleanup(self, nf, request): |
|
|
|
print 'docleanup' |
|
|
|
|
|
|
|
nf.addBoth(self.dostop) |
|
|
|
nf.addBoth(lambda x: self.dounlock(request)) |
|
|
|
nf.addErrback(self.logerr, 'nf') |
|
|
|
|
|
|
|
def dounlock(self, request): |
|
|
|
self._obj.unlock(request) |
|
|
|
# Cancel the error back so we don't get a warning. |
|
|
|
return None |
|
|
|
|
|
|
|
def dostop(self, arg): |
|
|
|
print 'dostop' |
|
|
|
d = self._obj.stop() |
|
|
|
# Make sure we have stopped before we continue |
|
|
|
d.addBoth(lambda x: arg) |
|
|
|
return d |
|
|
|
|
|
|
|
def logerr(self, exc, *args): |
|
|
|
print 'logerr:', `args` |
|
|
|
exc.printTraceback() |
|
|
|
#exc.printDetailedTraceback() |
|
|
|
|
|
|
|
def failed(self, exc, request): |
|
|
|
print 'in this failed case', self._obj.haslock(request), `request` |
|
|
|
if self._obj.haslock(request): |
|
|
|
self.dounlock(request) |
|
|
|
# XXX - look at queue and decide |
|
|
|
#request.responseHeaders.addRawHeader('Retry-After') |
|
|
|
res = error.ErrorPage(http.SERVICE_UNAVAILABLE, |
|
|
|
'failed w/ Exception', exc).render(request) |
|
|
|
request.write(res) |
|
|
|
request.finish() |
|
|
|
return exc |
|
|
|
|
|
|
|
def printarg(self, args): |
|
|
|
print 'pa:', `self`, `args` |
|
|
|
|
|
|
|
def unregisterProducer(self): |
|
|
|
resource.Resource.unregisterProducer(self) |
|
|
|
raise NotImplementedError |
|
|
|
|
|
|
|
class SLinkEChangerDiscTrack(MusicTrack): |
|
|
|
def __init__(self, *args, **kwargs): |
|
|
|
discobj = kwargs.pop('discobj') |
|
|
|
track = kwargs.pop('track') |
|
|
|
kwargs['content'] = ChangerTrack(self, discobj.getResource()) |
|
|
|
MusicTrack.__init__(self, *args, **kwargs) |
|
|
|
|
|
|
|
self._discobj = discobj |
|
|
|
self._track = track |
|
|
|
self.originalTrackNumber = str(track) |
|
|
|
|
|
|
|
if False: |
|
|
|
self.bitrate = bitrate |
|
|
|
|
|
|
|
self.url = '%s/%s' % (self.cd.urlbase, self.id) |
|
|
|
self.res = ResourceList() |
|
|
|
res = Resource(self.url, 'http-get:*:%s:*' % |
|
|
|
kwargs['content'].getmimetype()) |
|
|
|
l = self.getlength() |
|
|
|
if l is not None: |
|
|
|
res.duration = l |
|
|
|
self.res.append(res) |
|
|
|
|
|
|
|
def getlength(self): |
|
|
|
'''Returns None if length in seconds is unavailable.''' |
|
|
|
track = self._track |
|
|
|
cddbid = self._discobj.getID()[2:] |
|
|
|
cddbid[-1] = cddbid[-1] * 75 # frames/sec |
|
|
|
return (cddbid[track] - cddbid[track - 1]) // 75 |
|
|
|
|
|
|
|
def trylock(self, t, obj): |
|
|
|
return self._discobj.trylock(t, obj) |
|
|
|
|
|
|
|
def haslock(self, obj): |
|
|
|
return self._discobj.haslock(obj) |
|
|
|
|
|
|
|
def unlock(self, obj): |
|
|
|
self._discobj.unlock(obj) |
|
|
|
|
|
|
|
def cue(self): |
|
|
|
return self._discobj.cueTrack(self._track) |
|
|
|
|
|
|
|
def play(self): |
|
|
|
return self._discobj.play() |
|
|
|
|
|
|
|
def stop(self): |
|
|
|
return self._discobj.stop() |
|
|
|
|
|
|
|
class SLinkEChangerDisc(MusicAlbum): |
|
|
|
def __init__(self, *args, **kwargs): |
|
|
|
changer = kwargs.pop('changer') |
|
|
|
self._changer = changer |
|
|
|
disc = kwargs.pop('disc') |
|
|
|
self._disc = disc |
|
|
|
self._discnum = int(disc) |
|
|
|
self._lastid = None |
|
|
|
kwargs['content'] = ChangerTrack(self, self.getResource()) |
|
|
|
MusicAlbum.__init__(self, *args, **kwargs) |
|
|
|
|
|
|
|
self.url = '%s/%s' % (self.cd.urlbase, self.id) |
|
|
|
self.res = ResourceList() |
|
|
|
res = Resource(self.url, 'http-get:*:%s:*' % |
|
|
|
kwargs['content'].getmimetype()) |
|
|
|
l = self.getlength() |
|
|
|
if l is not None: |
|
|
|
res.duration = l |
|
|
|
self.res.append(res) |
|
|
|
|
|
|
|
def getlength(self): |
|
|
|
return self.getID()[-1] |
|
|
|
|
|
|
|
def trylock(self, t, obj): |
|
|
|
return self._changer.trylock(t, obj) |
|
|
|
|
|
|
|
def haslock(self, obj): |
|
|
|
return self._changer.haslock(obj) |
|
|
|
|
|
|
|
def unlock(self, obj): |
|
|
|
return self._changer.unlock(obj) |
|
|
|
|
|
|
|
def getResource(self): |
|
|
|
return self._changer.getResource() |
|
|
|
|
|
|
|
def getID(self): |
|
|
|
return self._changer.getID(self._disc) |
|
|
|
|
|
|
|
def cue(self): |
|
|
|
return self._changer.cueDisc(self._discnum, 1) |
|
|
|
|
|
|
|
def cueTrack(self, track): |
|
|
|
return self._changer.cueDisc(self._discnum, track) |
|
|
|
|
|
|
|
def play(self): |
|
|
|
return self._changer.play() |
|
|
|
|
|
|
|
def stop(self): |
|
|
|
return self._changer.stop() |
|
|
|
|
|
|
|
# ContentDirectory calls this |
|
|
|
def checkUpdate(self): |
|
|
|
print 'cU' |
|
|
|
curid = self.getID() |
|
|
|
if self._lastid != curid: |
|
|
|
print 'dU' |
|
|
|
self.doUpdate() |
|
|
|
self._lastid = curid |
|
|
|
|
|
|
|
def genChildren(self): |
|
|
|
return dict(('%02d' % i, i) for i in xrange(1, self.getID()[1] + 1)) |
|
|
|
|
|
|
|
def createObject(self, i, arg): |
|
|
|
return SLinkEChangerDiscTrack, i, (), { 'discobj': self, 'track': arg } |
|
|
|
|
|
|
|
# This is not a child of FSObject, since we will change the shelve on our own. |
|
|
|
class SLinkEChanger(StorageSystem, slinke.CDPlayerProtocol): |
|
|
|
def __init__(self, *args, **kwargs): |
|
|
|
s = filelock.LockShelve(kwargs.pop('file'), flag='rw') |
|
|
|
StorageSystem.__init__(self, *args, **kwargs) |
|
|
|
#slinke.CDPlayerProtocol.__init__(self) # XXX - none exists!?! |
|
|
|
self._changed = True |
|
|
|
self._lock = None |
|
|
|
self._poweroff = None |
|
|
|
self._pendinglocks = [] |
|
|
|
self._s = s |
|
|
|
reactor.addSystemEventTrigger('after', 'shutdown', |
|
|
|
self.aftershutdown) |
|
|
|
sl = slinke.SLinkE() |
|
|
|
config = s['config'] |
|
|
|
s = SerialPort(sl, config['serialport'], reactor, |
|
|
|
**config['serialkwargs']) |
|
|
|
th = slinke.SLinkProtocol() |
|
|
|
sl.openPort(config['port'], th) |
|
|
|
th.connectCDPlayer(config['cdnum'], self) |
|
|
|
self._audiores = LimitedAudioRes(config['audiodev'], |
|
|
|
config['audiodefault']) |
|
|
|
|
|
|
|
def aftershutdown(self): |
|
|
|
print 'in SLinkEChanger after shutdown' |
|
|
|
self._s.close() |
|
|
|
self._s = None |
|
|
|
|
|
|
|
def trylock(self, t, obj): |
|
|
|
'''Try to lock the Changer, timeout in t seconds. Associate |
|
|
|
lock w/ obj.''' |
|
|
|
# XXX - figure out how background tasks can be aborted. |
|
|
|
# (such as getting new CDDB ids) |
|
|
|
assert obj is not None, 'cannot use None as lock object' |
|
|
|
if self._lock is None: |
|
|
|
if self._poweroff is not None: |
|
|
|
self._poweroff.cancel() |
|
|
|
self._poweroff = None |
|
|
|
self._lock = obj |
|
|
|
print 'tl: locked:', `self._lock` |
|
|
|
return defer.succeed(True) |
|
|
|
|
|
|
|
d = defer.Deferred() |
|
|
|
d.addErrback(self.droppendinglockobj, obj) |
|
|
|
cl = reactor.callLater(t, d.errback, |
|
|
|
failure.Failure(RuntimeError('timed out waiting for lock'))) |
|
|
|
self._pendinglocks.append((d, obj, cl)) |
|
|
|
return d |
|
|
|
|
|
|
|
def droppendinglockobj(self, failure, obj): |
|
|
|
pobj = [ x for x in self._pendinglocks if x[1] is obj ][0] |
|
|
|
self._pendinglocks.remove(pobj) |
|
|
|
return failure |
|
|
|
|
|
|
|
def haslock(self, obj): |
|
|
|
if self._lock is obj: |
|
|
|
return True |
|
|
|
|
|
|
|
return False |
|
|
|
|
|
|
|
def unlock(self, obj): |
|
|
|
print 'unlock:', `obj` |
|
|
|
if self._lock is None: |
|
|
|
print 'ul: not locked' |
|
|
|
raise RuntimeError('unlocked when not locked') |
|
|
|
|
|
|
|
if obj is not self._lock: |
|
|
|
print 'ul: wrong obj' |
|
|
|
raise ValueError('unlocking when not locked by: %s, was locked by: %s' % (`obj`, self._lock)) |
|
|
|
|
|
|
|
if not self._pendinglocks: |
|
|
|
print 'really unlocked' |
|
|
|
self._lock = None |
|
|
|
self._poweroff = reactor.callLater(300, self.turnoff) |
|
|
|
return |
|
|
|
|
|
|
|
pobj = self._pendinglocks.pop(0) |
|
|
|
self._lock = pobj[1] |
|
|
|
print 'passing lock:', `self._lock` |
|
|
|
pobj[2].cancel() |
|
|
|
pobj[0].callback(True) |
|
|
|
|
|
|
|
@defer.deferredGenerator |
|
|
|
def turnoff(self): |
|
|
|
# needs to be first as checkids may reschedule |
|
|
|
# XXX - This really should only be done at start up, or |
|
|
|
# door open events. |
|
|
|
self._poweroff = None |
|
|
|
a = defer.waitForDeferred(self.checkids()) |
|
|
|
yield a |
|
|
|
a.getResult() |
|
|
|
print 'powering cd changer off' |
|
|
|
|
|
|
|
# checkids may have rescheduled us. If we don't cancel it, |
|
|
|
# we'd wake up every five minutes just to turn off again. |
|
|
|
if self._poweroff is not None: |
|
|
|
self._poweroff.cancel() |
|
|
|
self._poweroff = None |
|
|
|
a = defer.waitForDeferred(self.transport.poweroff()) |
|
|
|
yield a |
|
|
|
a.getResult() |
|
|
|
|
|
|
|
@defer.deferredGenerator |
|
|
|
def checkids(self): |
|
|
|
print 'starting checkids' |
|
|
|
a = defer.waitForDeferred(self.transport.poweron()) |
|
|
|
yield a |
|
|
|
print 'power should be on:', `a.getResult()` |
|
|
|
discs = list(self.transport.discs()) |
|
|
|
discs.sort() |
|
|
|
print discs |
|
|
|
for i in self.transport: |
|
|
|
discnum = i |
|
|
|
i = str(i) |
|
|
|
|
|
|
|
try: |
|
|
|
id = self.getID(i) |
|
|
|
#print `i`, `id`, `self._s[i]` |
|
|
|
continue |
|
|
|
except KeyError: |
|
|
|
pass |
|
|
|
|
|
|
|
print 'missing:', `i` |
|
|
|
|
|
|
|
# No ID, fetch it. |
|
|
|
a = defer.waitForDeferred(self.trylock(5, self)) |
|
|
|
yield a |
|
|
|
a.getResult() |
|
|
|
|
|
|
|
try: |
|
|
|
a = defer.waitForDeferred(self.transport.getcddbid(discnum)) |
|
|
|
yield a |
|
|
|
cddbid = a.getResult() |
|
|
|
a = defer.waitForDeferred(threads.deferToThread(CDDB.query, cddbid)) |
|
|
|
yield a |
|
|
|
queryres = a.getResult() |
|
|
|
print 'res:', `i`, `queryres` |
|
|
|
self._s[i] = { 'query': queryres, |
|
|
|
'cddbid': cddbid, } |
|
|
|
self._changed = True |
|
|
|
except slinke.NoDisc: |
|
|
|
print 'Disc not present: %d' % discnum |
|
|
|
continue |
|
|
|
finally: |
|
|
|
self.unlock(self) |
|
|
|
|
|
|
|
def cueDisc(self, disc, track): |
|
|
|
# int is here since we talk about discs as strings |
|
|
|
return self.transport.cuedisc(int(disc), track) |
|
|
|
|
|
|
|
def play(self): |
|
|
|
return self.transport.play() |
|
|
|
|
|
|
|
def stop(self): |
|
|
|
return self.transport.stop() |
|
|
|
|
|
|
|
def getResource(self): |
|
|
|
return self._audiores |
|
|
|
|
|
|
|
def getID(self, disc): |
|
|
|
try: |
|
|
|
return self._s[disc]['cddbid'] |
|
|
|
except KeyError: |
|
|
|
if int(disc) == self.transport.curdisc: |
|
|
|
pass |
|
|
|
raise |
|
|
|
|
|
|
|
def getMatch(self, disc, wait=False): |
|
|
|
q = self._s[disc]['query'] |
|
|
|
if q[0] == 200: |
|
|
|
return q[1] |
|
|
|
elif q[0] == 202: |
|
|
|
return |
|
|
|
elif q[0] in (211, 210): |
|
|
|
# 210 multiple matches |
|
|
|
return q[1][0] |
|
|
|
else: |
|
|
|
raise ValueError('what to do? %s' % `self._s[disc]`) |
|
|
|
|
|
|
|
def getDiscTitle(self, disc, wait=False): |
|
|
|
m = self.getMatch(disc, wait) |
|
|
|
if m is None: |
|
|
|
return '%03d' % int(disc) |
|
|
|
|
|
|
|
t = m['title'] |
|
|
|
try: |
|
|
|
t = t.decode('utf8') |
|
|
|
except UnicodeDecodeError: |
|
|
|
t = t.decode('iso8859-1') |
|
|
|
|
|
|
|
if t.count('/') > 1: |
|
|
|
print 'tcount:', `t` |
|
|
|
try: |
|
|
|
return t.split('/', 1)[1] |
|
|
|
except IndexError: |
|
|
|
print 'ie:', `t` |
|
|
|
return t |
|
|
|
|
|
|
|
# CDPlayerProtocol interface |
|
|
|
def connectionMade(self): |
|
|
|
super(SLinkEChanger, self).connectionMade() |
|
|
|
print 'attached to cdplayer' |
|
|
|
self._changed = True |
|
|
|
|
|
|
|
# Make sure we start out off, or if we are missing CDDB id's, |
|
|
|
# that we get them. |
|
|
|
self.turnoff() |
|
|
|
|
|
|
|
def stateChange(self): |
|
|
|
print 'sC' |
|
|
|
|
|
|
|
# ContentDirectory calls this |
|
|
|
def checkUpdate(self): |
|
|
|
if self._changed: |
|
|
|
self._changed = False |
|
|
|
self.doUpdate() |
|
|
|
|
|
|
|
# StorageSystem interface |
|
|
|
def genChildren(self): |
|
|
|
return dict((self.getDiscTitle(x), |
|
|
|
x) for x in self._s if isint(x) and self.transport and |
|
|
|
int(x) in self.transport) |
|
|
|
|
|
|
|
#def genCurrent(self): |
|
|
|
|
|
|
|
def createObject(self, i, arg): |
|
|
|
return SLinkEChangerDisc, i, (), { 'changer': self, 'disc': arg } |
|
|
|
|
|
|
|
def detectslinkemod(origpath, fobj): |
|
|
|
# XXX - shelve adds the extension back |
|
|
|
origpath, ext = os.path.splitext(origpath) |
|
|
|
if ext == '.lock': |
|
|
|
return None, None |
|
|
|
|
|
|
|
s = shelve.open(origpath, 'r') |
|
|
|
if 'config' in s: |
|
|
|
# XXX - expand detection |
|
|
|
return SLinkEChanger, { 'file': origpath } |
|
|
|
|
|
|
|
return None, None |
|
|
|
|
|
|
|
if __name__ != '__main__': |
|
|
|
registerklassfun(detectslinkemod, debug=False) |
|
|
|
else: |
|
|
|
# do create work |
|
|
|
pass |