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.

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