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.

328 lines
8.2 KiB

  1. #!/usr/bin/env python
  2. # Copyright 2006-2008 John-Mark Gurney <jmg@funkthat.com>
  3. __version__ = '$Change$'
  4. # $Id$
  5. ffmpeg_path = '/usr/local/bin/ffmpeg'
  6. import FileDIDL
  7. import errno
  8. import itertools
  9. import os
  10. import sets
  11. import stat
  12. import urlparse
  13. from DIDLLite import Container, StorageFolder, Item, VideoItem, AudioItem, TextItem, ImageItem, Resource, ResourceList
  14. from twisted.web import resource, server, static
  15. from twisted.python import log
  16. from twisted.internet import abstract, interfaces, process, protocol, reactor
  17. from zope.interface import implements
  18. __all__ = [ 'registerklassfun', 'registerfiletoignore',
  19. 'FSObject', 'FSItem', 'FSDirectory',
  20. 'FSVideoItem', 'FSAudioItem', 'FSTextItem', 'FSImageItem',
  21. 'mimetoklass',
  22. ]
  23. mimedict = static.loadMimeTypes()
  24. _klassfuns = []
  25. def registerklassfun(fun):
  26. _klassfuns.append(fun)
  27. _filestoignore = {
  28. '.DS_Store': None
  29. }
  30. def registerfiletoignore(f):
  31. _filestoignore[f] = None
  32. # Return this class when you want the file to be skipped. If you return this,
  33. # no other modules will be applied, and it won't be added. Useful for things
  34. # like .DS_Store which are known to useless on a media server.
  35. class IgnoreFile:
  36. pass
  37. def statcmp(a, b, cmpattrs = [ 'st_ino', 'st_dev', 'st_size', 'st_mtime', ]):
  38. if a is None or b is None:
  39. return False
  40. for i in cmpattrs:
  41. if getattr(a, i) != getattr(b, i):
  42. return False
  43. return True
  44. class FSObject(object):
  45. def __init__(self, path):
  46. self.FSpath = path
  47. self.pstat = None
  48. def checkUpdate(self):
  49. # need to handle no such file or directory
  50. # push it up? but still need to handle disappearing
  51. try:
  52. nstat = os.stat(self.FSpath)
  53. if statcmp(self.pstat, nstat):
  54. return self
  55. self.pstat = nstat
  56. self.doUpdate()
  57. except OSError, x:
  58. log.msg('os.stat, OSError: %s' % x)
  59. if x.errno in (errno.ENOENT, errno.ENOTDIR, errno.EPERM, ):
  60. # We can't access it anymore, delete it
  61. self.cd.delItem(self.id)
  62. return None
  63. else:
  64. raise
  65. return self
  66. def doUpdate(self):
  67. raise NotImplementedError
  68. def __repr__(self):
  69. return '<%s.%s: path: %s, id: %s, parent: %s, title: %s>' % \
  70. (self.__class__.__module__, self.__class__.__name__,
  71. self.FSpath, self.id, self.parentID, self.title)
  72. class NullConsumer(file, abstract.FileDescriptor):
  73. implements(interfaces.IConsumer)
  74. def __init__(self):
  75. file.__init__(self, '/dev/null', 'w')
  76. abstract.FileDescriptor.__init__(self)
  77. def write(self, data):
  78. pass
  79. class DynamTransfer(protocol.ProcessProtocol):
  80. def __init__(self, path, mods, request):
  81. self.path = path
  82. self.mods = mods
  83. self.request = request
  84. def outReceived(self, data):
  85. self.request.write(data)
  86. def outConnectionLost(self):
  87. if self.request:
  88. self.request.unregisterProducer()
  89. self.request.finish()
  90. self.request = None
  91. def errReceived(self, data):
  92. pass
  93. #log.msg(data)
  94. def stopProducing(self):
  95. if self.request:
  96. self.request.unregisterProducer()
  97. self.request.finish()
  98. if self.proc:
  99. self.proc.loseConnection()
  100. self.proc.signalProcess('INT')
  101. self.request = None
  102. self.proc = None
  103. pauseProducing = lambda x: x.proc.pauseProducing()
  104. resumeProducing = lambda x: x.proc.resumeProducing()
  105. def render(self):
  106. mods = self.mods
  107. path = self.path
  108. request = self.request
  109. vcodec = mods[0]
  110. if mods[0] not in ('xvid', 'mpeg2', ):
  111. vcodec = 'xvid'
  112. mimetype = { 'xvid': 'video/avi', 'mpeg2': 'video/mpeg', }
  113. mimetype = { 'xvid': 'video/x-msvideo', 'mpeg2': 'video/mpeg', }
  114. request.setHeader('content-type', mimetype[vcodec])
  115. if request.method == 'HEAD':
  116. return ''
  117. audiomp3 = [ '-acodec', 'mp3', '-ab', '192', ]
  118. audiomp2 = [ '-acodec', 'mp2', '-ab', '256', ]
  119. optdict = {
  120. 'xvid': [ '-vcodec', 'xvid',
  121. #'-mv4', '-gmc', '-g', '240',
  122. '-f', 'avi', ] + audiomp3,
  123. 'mpeg2': [ '-vcodec', 'mpeg2video', #'-g', '60',
  124. '-f', 'mpeg', ] + audiomp2,
  125. }
  126. args = [ 'ffmpeg', '-i', path, '-b', '4000',
  127. #'-sc_threshold', '500000', '-b_strategy', '1', '-max_b_frames', '6',
  128. ] + optdict[vcodec] + [ '-', ]
  129. #log.msg(*[`i` for i in args])
  130. self.proc = process.Process(reactor, ffmpeg_path, args,
  131. None, None, self)
  132. self.proc.closeStdin()
  133. request.registerProducer(self, 1)
  134. return server.NOT_DONE_YET
  135. class DynamicTrans(resource.Resource):
  136. isLeaf = True
  137. def __init__(self, path, notrans):
  138. self.path = path
  139. self.notrans = notrans
  140. def render(self, request):
  141. #if request.getHeader('getcontentfeatures.dlna.org'):
  142. # request.setHeader('contentFeatures.dlna.org', 'DLNA.ORG_OP=01;DLNA.ORG_CI=0')
  143. # # we only want the headers
  144. # self.notrans.render(request)
  145. # request.unregisterProducer()
  146. # return ''
  147. if request.postpath:
  148. # Translation request
  149. return DynamTransfer(self.path, request.postpath, request).render()
  150. else:
  151. return self.notrans.render(request)
  152. class FSItem(FSObject, Item):
  153. def __init__(self, *args, **kwargs):
  154. FSObject.__init__(self, kwargs['path'])
  155. del kwargs['path']
  156. mimetype = kwargs['mimetype']
  157. del kwargs['mimetype']
  158. kwargs['content'] = DynamicTrans(self.FSpath,
  159. static.File(self.FSpath, mimetype))
  160. Item.__init__(self, *args, **kwargs)
  161. self.url = urlparse.urljoin(self.cd.urlbase, self.id)
  162. self.mimetype = mimetype
  163. self.checkUpdate()
  164. def doUpdate(self):
  165. #print 'FSItem doUpdate:', `self`
  166. self.res = ResourceList()
  167. r = Resource(self.url, 'http-get:*:%s:*' % self.mimetype)
  168. r.size = os.path.getsize(self.FSpath)
  169. self.res.append(r)
  170. if self.mimetype.split('/', 1)[0] == 'video':
  171. self.res.append(Resource(self.url + '/mpeg2',
  172. 'http-get:*:%s:*' % 'video/mpeg'))
  173. self.res.append(Resource(self.url + '/xvid',
  174. 'http-get:*:%s:*' % 'video/x-msvideo'))
  175. Item.doUpdate(self)
  176. def ignoreFiles(path, fobj):
  177. bn = os.path.basename(path)
  178. if bn in _filestoignore:
  179. return IgnoreFile, None
  180. elif bn[:2] == '._' and open(path).read(4) == '\x00\x05\x16\x07':
  181. # AppleDouble encoded Macintosh Resource Fork
  182. return IgnoreFile, None
  183. return None, None
  184. def defFS(path, fobj):
  185. if os.path.isdir(path):
  186. # new dir
  187. return FSDirectory, { 'path': path }
  188. elif os.path.isfile(path):
  189. # new file - fall through to below
  190. pass
  191. else:
  192. log.msg('skipping (not dir or reg): %s' % path)
  193. return None, None
  194. klass, mt = FileDIDL.buildClassMT(FSItem, path)
  195. return klass, { 'path': path, 'mimetype': mt }
  196. def dofileadd(cd, parent, path, name):
  197. klass = None
  198. fsname = os.path.join(path, name)
  199. try:
  200. fobj = open(fsname)
  201. except:
  202. fobj = None
  203. for i in itertools.chain(( ignoreFiles, ), _klassfuns, ( defFS, )):
  204. try:
  205. try:
  206. fobj.seek(0) # incase the call expects a clean file
  207. except:
  208. pass
  209. #log.msg('testing:', `i`, `fsname`, `fobj`)
  210. klass, kwargs = i(fsname, fobj)
  211. if klass is not None:
  212. break
  213. except:
  214. #import traceback
  215. #traceback.print_exc(file=log.logfile)
  216. pass
  217. if klass is None or klass is IgnoreFile:
  218. return
  219. #print 'matched:', os.path.join(path, name), `i`, `klass`
  220. return cd.addItem(parent, klass, name, **kwargs)
  221. class FSDirectory(FSObject, StorageFolder):
  222. def __init__(self, *args, **kwargs):
  223. path = kwargs['path']
  224. del kwargs['path']
  225. StorageFolder.__init__(self, *args, **kwargs)
  226. FSObject.__init__(self, path)
  227. # mapping from path to objectID
  228. self.pathObjmap = {}
  229. self.indoUpdate = False
  230. def doUpdate(self):
  231. # We need to rescan this dir, and see if our children has
  232. # changed any.
  233. if self.indoUpdate:
  234. return
  235. #import traceback
  236. #traceback.print_stack()
  237. self.indoUpdate = True
  238. doupdate = False
  239. children = sets.Set(os.listdir(self.FSpath))
  240. for i in self.pathObjmap.keys():
  241. if i not in children:
  242. doupdate = True
  243. # delete
  244. self.cd.delItem(self.pathObjmap[i])
  245. del self.pathObjmap[i]
  246. for i in children:
  247. if i in self.pathObjmap:
  248. continue
  249. # new object
  250. nf = dofileadd(self.cd, self.id, self.FSpath, i)
  251. if nf is not None:
  252. doupdate = True
  253. self.pathObjmap[i] = nf
  254. # sort our children
  255. self.sort(lambda x, y: cmp(x.title, y.title))
  256. # Pass up to handle UpdateID
  257. if doupdate:
  258. # Calling StorageFolder.doUpdate results in calling
  259. # ourselves.
  260. Container.doUpdate(self)
  261. self.indoUpdate = False
  262. def __repr__(self):
  263. return ('<%s.%s: path: %s, id: %s, parent: %s, title: %s, ' + \
  264. 'cnt: %d>') % (self.__class__.__module__,
  265. self.__class__.__name__, self.FSpath, self.id,
  266. self.parentID, self.title, len(self))