A Python UPnP Media Server
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

559 lines
14 KiB

  1. #!/usr/bin/env python
  2. # Copyright 2009 John-Mark Gurney <jmg@funkthat.com>
  3. '''CD Changer Module'''
  4. __version__ = '$Change$'
  5. # $Id$
  6. import CDDB
  7. import audio
  8. import filelock
  9. import os.path
  10. import shelve
  11. import slinke
  12. from DIDLLite import Container, StorageSystem, StorageVolume
  13. from DIDLLite import MusicGenre, MusicTrack, MusicAlbum, Resource, ResourceList
  14. from FSStorage import registerklassfun
  15. from twisted.python import log, threadable, failure
  16. from twisted.internet import defer, protocol, reactor, threads
  17. from twisted.internet.interfaces import IPushProducer, IPullProducer, IConsumer
  18. from twisted.internet.serialport import SerialPort
  19. from twisted.web import error, http, resource, server
  20. from zope.interface import implements
  21. def isint(s):
  22. try:
  23. i = int(s)
  24. return True
  25. except ValueError:
  26. return False
  27. class LimitedAudioProducer(object):
  28. implements(IPushProducer, IPullProducer, IConsumer)
  29. # request, args
  30. def __init__(self, consumer, *args, **kwargs):
  31. self.remain = self.setlength(consumer)
  32. self.consumer = consumer
  33. audio.AudioPlayer(self, *args, **kwargs)
  34. @staticmethod
  35. def setlength(request):
  36. if request.responseHeaders.hasHeader('content-length'):
  37. secs = int(
  38. request.responseHeaders.getRawHeaders(
  39. 'content-length')[0].strip())
  40. r = secs * audio.bytespersecmt(
  41. request.responseHeaders.getRawHeaders(
  42. 'content-type')[0])
  43. request.responseHeaders.setRawHeaders(
  44. 'content-length', [ str(r) ])
  45. else:
  46. r = None
  47. return r
  48. def shutdown(self):
  49. print('lap: shutdown', repr(self.consumer))
  50. # XXX - I still get writes after I've asked my producer to stop
  51. self.write = lambda x: None
  52. self.producer.stopProducing()
  53. self.producer = None
  54. self.consumer.unregisterProducer()
  55. self.consumer = None
  56. #self.remain = 0 # No more writes
  57. # IPushProducer
  58. def pauseProducing(self):
  59. return self.producer.pauseProducing()
  60. def resumeProducing(self):
  61. return self.producer.resumeProducing()
  62. def stopProducing(self):
  63. print('lap: sp')
  64. self.shutdown()
  65. # IConsumer
  66. def registerProducer(self, producer, streaming):
  67. print('lap: regp')
  68. self.producer = producer
  69. self.streamingProducer = streaming
  70. self.consumer.registerProducer(self, streaming)
  71. def unregisterProducer():
  72. print('lap: unregp')
  73. self.shutdown()
  74. def write(self, data):
  75. if self.remain is not None:
  76. data = data[:self.remain]
  77. self.remain -= len(data)
  78. self.consumer.write(data)
  79. if self.remain is not None and not self.remain:
  80. # Done producing
  81. self.shutdown()
  82. class LimitedAudioRes(audio.AudioResource):
  83. producerFactory = LimitedAudioProducer
  84. def render(self, request):
  85. r = audio.AudioResource.render(self, request)
  86. if request.method == 'HEAD':
  87. LimitedAudioProducer.setlength(request)
  88. return r
  89. class ChangerTrack(resource.Resource):
  90. isLeaf = True
  91. def __init__(self, obj, res):
  92. resource.Resource.__init__(self)
  93. self._obj = obj
  94. self._res = res
  95. def getmimetype(self):
  96. return self._res.getmimetype()
  97. def render(self, request):
  98. print('CTrender:', repr(request.postpath), repr(request.method), repr(request))
  99. self.setlength(request)
  100. if request.method == 'HEAD':
  101. return self._res.render(request)
  102. # Needs to be created here so that it will fire.
  103. # If not, the list may be walked before we add it.
  104. nf = request.notifyFinish()
  105. d = self._obj.trylock(20, request)
  106. d.addCallback(lambda x: self._obj.cue())
  107. # XXX - how to stop playback when track is done?
  108. #d.addCallback(lambda x: self.setlength(request))
  109. d.addCallback(lambda x: self.printarg('before render, after cue'))
  110. d.addCallback(lambda x: self._res.render(request))
  111. d.addCallback(lambda x: self.docleanup(nf, request))
  112. d.addErrback(lambda exc: self.failed(exc, request))
  113. # XXX - add errBack?
  114. d.addCallback(lambda x: self.printarg('after render, before play'))
  115. d.addCallback(lambda x: self._obj.play())
  116. d.addErrback(self.logerr)
  117. return server.NOT_DONE_YET
  118. def setlength(self, request):
  119. r = self._obj.getlength()
  120. request.responseHeaders.setRawHeaders(
  121. 'content-length', [ str(r) ])
  122. def docleanup(self, nf, request):
  123. print('docleanup')
  124. nf.addBoth(self.dostop)
  125. nf.addBoth(lambda x: self.dounlock(request))
  126. nf.addErrback(self.logerr, 'nf')
  127. def dounlock(self, request):
  128. self._obj.unlock(request)
  129. # Cancel the error back so we don't get a warning.
  130. return None
  131. def dostop(self, arg):
  132. print('dostop')
  133. d = self._obj.stop()
  134. # Make sure we have stopped before we continue
  135. d.addBoth(lambda x: arg)
  136. return d
  137. def logerr(self, exc, *args):
  138. print('logerr:', repr(args))
  139. exc.printTraceback()
  140. #exc.printDetailedTraceback()
  141. def failed(self, exc, request):
  142. print('in this failed case', self._obj.haslock(request), repr(request))
  143. if self._obj.haslock(request):
  144. self.dounlock(request)
  145. # XXX - look at queue and decide
  146. #request.responseHeaders.addRawHeader('Retry-After')
  147. res = error.ErrorPage(http.SERVICE_UNAVAILABLE,
  148. 'failed w/ Exception', exc).render(request)
  149. request.write(res)
  150. request.finish()
  151. return exc
  152. def printarg(self, args):
  153. print('pa:', repr(self), repr(args))
  154. def unregisterProducer(self):
  155. resource.Resource.unregisterProducer(self)
  156. raise NotImplementedError
  157. class SLinkEChangerDiscTrack(MusicTrack):
  158. def __init__(self, *args, **kwargs):
  159. discobj = kwargs.pop('discobj')
  160. track = kwargs.pop('track')
  161. kwargs['content'] = ChangerTrack(self, discobj.getResource())
  162. MusicTrack.__init__(self, *args, **kwargs)
  163. self._discobj = discobj
  164. self._track = track
  165. self.originalTrackNumber = str(track)
  166. if False:
  167. self.bitrate = bitrate
  168. self.url = '%s/%s' % (self.cd.urlbase, self.id)
  169. self.res = ResourceList()
  170. res = Resource(self.url, 'http-get:*:%s:*' %
  171. kwargs['content'].getmimetype())
  172. l = self.getlength()
  173. if l is not None:
  174. res.duration = l
  175. self.res.append(res)
  176. def getlength(self):
  177. '''Returns None if length in seconds is unavailable.'''
  178. track = self._track
  179. cddbid = self._discobj.getID()[2:]
  180. cddbid[-1] = cddbid[-1] * 75 # frames/sec
  181. return (cddbid[track] - cddbid[track - 1]) // 75
  182. def trylock(self, t, obj):
  183. return self._discobj.trylock(t, obj)
  184. def haslock(self, obj):
  185. return self._discobj.haslock(obj)
  186. def unlock(self, obj):
  187. self._discobj.unlock(obj)
  188. def cue(self):
  189. return self._discobj.cueTrack(self._track)
  190. def play(self):
  191. return self._discobj.play()
  192. def stop(self):
  193. return self._discobj.stop()
  194. class SLinkEChangerDisc(MusicAlbum):
  195. def __init__(self, *args, **kwargs):
  196. changer = kwargs.pop('changer')
  197. self._changer = changer
  198. disc = kwargs.pop('disc')
  199. self._disc = disc
  200. self._discnum = int(disc)
  201. self._lastid = None
  202. kwargs['content'] = ChangerTrack(self, self.getResource())
  203. MusicAlbum.__init__(self, *args, **kwargs)
  204. self.url = '%s/%s' % (self.cd.urlbase, self.id)
  205. self.res = ResourceList()
  206. res = Resource(self.url, 'http-get:*:%s:*' %
  207. kwargs['content'].getmimetype())
  208. l = self.getlength()
  209. if l is not None:
  210. res.duration = l
  211. self.res.append(res)
  212. def getlength(self):
  213. return self.getID()[-1]
  214. def trylock(self, t, obj):
  215. return self._changer.trylock(t, obj)
  216. def haslock(self, obj):
  217. return self._changer.haslock(obj)
  218. def unlock(self, obj):
  219. return self._changer.unlock(obj)
  220. def getResource(self):
  221. return self._changer.getResource()
  222. def getID(self):
  223. return self._changer.getID(self._disc)
  224. def cue(self):
  225. return self._changer.cueDisc(self._discnum, 1)
  226. def cueTrack(self, track):
  227. return self._changer.cueDisc(self._discnum, track)
  228. def play(self):
  229. return self._changer.play()
  230. def stop(self):
  231. return self._changer.stop()
  232. # ContentDirectory calls this
  233. def checkUpdate(self):
  234. print('cU')
  235. curid = self.getID()
  236. if self._lastid != curid:
  237. print('dU')
  238. self.doUpdate()
  239. self._lastid = curid
  240. def genChildren(self):
  241. return dict(('%02d' % i, i) for i in range(1, self.getID()[1] + 1))
  242. def createObject(self, i, arg):
  243. return SLinkEChangerDiscTrack, i, (), { 'discobj': self, 'track': arg }
  244. # This is not a child of FSObject, since we will change the shelve on our own.
  245. class SLinkEChanger(StorageSystem, slinke.CDPlayerProtocol):
  246. def __init__(self, *args, **kwargs):
  247. s = filelock.LockShelve(kwargs.pop('file'), flag='rw')
  248. StorageSystem.__init__(self, *args, **kwargs)
  249. #slinke.CDPlayerProtocol.__init__(self) # XXX - none exists!?!
  250. self._changed = True
  251. self._lock = None
  252. self._poweroff = None
  253. self._pendinglocks = []
  254. self._s = s
  255. reactor.addSystemEventTrigger('after', 'shutdown',
  256. self.aftershutdown)
  257. sl = slinke.SLinkE()
  258. config = s['config']
  259. s = SerialPort(sl, config['serialport'], reactor,
  260. **config['serialkwargs'])
  261. th = slinke.SLinkProtocol()
  262. sl.openPort(config['port'], th)
  263. th.connectCDPlayer(config['cdnum'], self)
  264. self._audiores = LimitedAudioRes(config['audiodev'],
  265. config['audiodefault'])
  266. def aftershutdown(self):
  267. print('in SLinkEChanger after shutdown')
  268. self._s.close()
  269. self._s = None
  270. def trylock(self, t, obj):
  271. '''Try to lock the Changer, timeout in t seconds. Associate
  272. lock w/ obj.'''
  273. # XXX - figure out how background tasks can be aborted.
  274. # (such as getting new CDDB ids)
  275. assert obj is not None, 'cannot use None as lock object'
  276. if self._lock is None:
  277. if self._poweroff is not None:
  278. self._poweroff.cancel()
  279. self._poweroff = None
  280. self._lock = obj
  281. print('tl: locked:', repr(self._lock))
  282. return defer.succeed(True)
  283. d = defer.Deferred()
  284. d.addErrback(self.droppendinglockobj, obj)
  285. cl = reactor.callLater(t, d.errback,
  286. failure.Failure(RuntimeError('timed out waiting for lock')))
  287. self._pendinglocks.append((d, obj, cl))
  288. return d
  289. def droppendinglockobj(self, failure, obj):
  290. pobj = [ x for x in self._pendinglocks if x[1] is obj ][0]
  291. self._pendinglocks.remove(pobj)
  292. return failure
  293. def haslock(self, obj):
  294. if self._lock is obj:
  295. return True
  296. return False
  297. def unlock(self, obj):
  298. print('unlock:', repr(obj))
  299. if self._lock is None:
  300. print('ul: not locked')
  301. raise RuntimeError('unlocked when not locked')
  302. if obj is not self._lock:
  303. print('ul: wrong obj')
  304. raise ValueError('unlocking when not locked by: %s, was locked by: %s' % (repr(obj), self._lock))
  305. if not self._pendinglocks:
  306. print('really unlocked')
  307. self._lock = None
  308. self._poweroff = reactor.callLater(300, self.turnoff)
  309. return
  310. pobj = self._pendinglocks.pop(0)
  311. self._lock = pobj[1]
  312. print('passing lock:', repr(self._lock))
  313. pobj[2].cancel()
  314. pobj[0].callback(True)
  315. @defer.deferredGenerator
  316. def turnoff(self):
  317. # needs to be first as checkids may reschedule
  318. # XXX - This really should only be done at start up, or
  319. # door open events.
  320. self._poweroff = None
  321. a = defer.waitForDeferred(self.checkids())
  322. yield a
  323. a.getResult()
  324. print('powering cd changer off')
  325. # checkids may have rescheduled us. If we don't cancel it,
  326. # we'd wake up every five minutes just to turn off again.
  327. if self._poweroff is not None:
  328. self._poweroff.cancel()
  329. self._poweroff = None
  330. a = defer.waitForDeferred(self.transport.poweroff())
  331. yield a
  332. a.getResult()
  333. @defer.deferredGenerator
  334. def checkids(self):
  335. print('starting checkids')
  336. a = defer.waitForDeferred(self.transport.poweron())
  337. yield a
  338. print('power should be on:', repr(a.getResult()))
  339. discs = sorted(self.transport.discs())
  340. print(discs)
  341. for i in self.transport:
  342. discnum = i
  343. i = str(i)
  344. try:
  345. id = self.getID(i)
  346. #print `i`, `id`, `self._s[i]`
  347. continue
  348. except KeyError:
  349. pass
  350. print('missing:', repr(i))
  351. # No ID, fetch it.
  352. a = defer.waitForDeferred(self.trylock(5, self))
  353. yield a
  354. a.getResult()
  355. try:
  356. a = defer.waitForDeferred(self.transport.getcddbid(discnum))
  357. yield a
  358. cddbid = a.getResult()
  359. a = defer.waitForDeferred(threads.deferToThread(CDDB.query, cddbid))
  360. yield a
  361. queryres = a.getResult()
  362. print('res:', repr(i), repr(queryres))
  363. self._s[i] = { 'query': queryres,
  364. 'cddbid': cddbid, }
  365. self._changed = True
  366. except slinke.NoDisc:
  367. print('Disc not present: %d' % discnum)
  368. continue
  369. finally:
  370. self.unlock(self)
  371. def cueDisc(self, disc, track):
  372. # int is here since we talk about discs as strings
  373. return self.transport.cuedisc(int(disc), track)
  374. def play(self):
  375. return self.transport.play()
  376. def stop(self):
  377. return self.transport.stop()
  378. def getResource(self):
  379. return self._audiores
  380. def getID(self, disc):
  381. try:
  382. return self._s[disc]['cddbid']
  383. except KeyError:
  384. if int(disc) == self.transport.curdisc:
  385. pass
  386. raise
  387. def getMatch(self, disc, wait=False):
  388. q = self._s[disc]['query']
  389. if q[0] == 200:
  390. return q[1]
  391. elif q[0] == 202:
  392. return
  393. elif q[0] in (211, 210):
  394. # 210 multiple matches
  395. return q[1][0]
  396. else:
  397. raise ValueError('what to do? %s' % repr(self._s[disc]))
  398. def getDiscTitle(self, disc, wait=False):
  399. m = self.getMatch(disc, wait)
  400. if m is None:
  401. return '%03d' % int(disc)
  402. t = m['title']
  403. try:
  404. t = t.decode('utf8')
  405. except UnicodeDecodeError:
  406. t = t.decode('iso8859-1')
  407. if t.count('/') > 1:
  408. print('tcount:', repr(t))
  409. try:
  410. return t.split('/', 1)[1]
  411. except IndexError:
  412. print('ie:', repr(t))
  413. return t
  414. # CDPlayerProtocol interface
  415. def connectionMade(self):
  416. super(SLinkEChanger, self).connectionMade()
  417. print('attached to cdplayer')
  418. self._changed = True
  419. # Make sure we start out off, or if we are missing CDDB id's,
  420. # that we get them.
  421. self.turnoff()
  422. def stateChange(self):
  423. print('sC')
  424. # ContentDirectory calls this
  425. def checkUpdate(self):
  426. if self._changed:
  427. self._changed = False
  428. self.doUpdate()
  429. # StorageSystem interface
  430. def genChildren(self):
  431. return dict((self.getDiscTitle(x),
  432. x) for x in self._s if isint(x) and self.transport and
  433. int(x) in self.transport)
  434. #def genCurrent(self):
  435. def createObject(self, i, arg):
  436. return SLinkEChangerDisc, i, (), { 'changer': self, 'disc': arg }
  437. def detectslinkemod(origpath, fobj):
  438. # XXX - shelve adds the extension back
  439. origpath, ext = os.path.splitext(origpath)
  440. if ext == '.lock':
  441. return None, None
  442. s = shelve.open(origpath, 'r')
  443. if 'config' in s:
  444. # XXX - expand detection
  445. return SLinkEChanger, { 'file': origpath }
  446. return None, None
  447. if __name__ != '__main__':
  448. registerklassfun(detectslinkemod, debug=False)
  449. else:
  450. # do create work
  451. pass