video.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import logging
  2. # import os
  3. import re
  4. import tempfile
  5. import threading
  6. from lvc import execute
  7. from lvc.widgets import idle_add
  8. from lvc.settings import get_ffmpeg_executable_path
  9. from lvc.utils import hms_to_seconds, convert_path_for_subprocess
  10. logger = logging.getLogger(__name__)
  11. class VideoFile(object):
  12. def __init__(self, filename):
  13. self.filename = filename
  14. self.container = None
  15. self.video_codec = None
  16. self.audio_codec = None
  17. self.width = None
  18. self.height = None
  19. self.duration = None
  20. self.thumbnails = {}
  21. self.parse()
  22. def parse(self):
  23. self.__dict__.update(
  24. get_media_info(self.filename))
  25. @property
  26. def audio_only(self):
  27. return self.video_codec is None
  28. def get_thumbnail(self, completion, width=None, height=None, type_='.png'):
  29. if self.audio_only:
  30. # don't bother with thumbnails for audio files
  31. return None
  32. if width is None:
  33. width = -1
  34. if height is None:
  35. height = -1
  36. if self.duration is None:
  37. skip = 0
  38. else:
  39. skip = min(int(self.duration / 3), 120)
  40. key = (width, height, type_)
  41. def complete(name):
  42. self.thumbnails[key] = name
  43. completion()
  44. if key not in self.thumbnails:
  45. temp_path = tempfile.mktemp(suffix=type_)
  46. get_thumbnail(self.filename, width, height, temp_path, complete,
  47. skip=skip)
  48. return None
  49. return self.thumbnails.get(key)
  50. class Node(object):
  51. def __init__(self, line="", children=None):
  52. self.line = line
  53. if not children:
  54. self.children = []
  55. else:
  56. self.children = children
  57. if ": " in line:
  58. self.key, self.value = line.split(": ", 1)
  59. else:
  60. self.key = ""
  61. self.value = ""
  62. def add_node(self, node):
  63. self.children.append(node)
  64. def pformat(self, indent=0):
  65. s = (" " * indent) + ("Node: %s" % self.line) + "\n"
  66. for mem in self.children:
  67. s += mem.pformat(indent + 2)
  68. return s
  69. def get_by_key(self, key):
  70. if self.line.startswith(key):
  71. return self
  72. for mem in self.children:
  73. ret = mem.get_by_key(key)
  74. if ret:
  75. return ret
  76. return None
  77. def __repr__(self):
  78. return "<Node %s: %s>" % (self.key, self.value)
  79. def get_indent(line):
  80. length = len(line)
  81. line = line.lstrip()
  82. return (length - len(line), line)
  83. def parse_ffmpeg_output(output):
  84. """Takes a list of strings and parses it into a loose AST-ish
  85. thing.
  86. ffmpeg output uses indentation levels to indicate a hierarchy of
  87. data.
  88. If there's a : in the line, then it's probably a key/value pair.
  89. :param output: the content to parse as a list of strings.
  90. :returns: a top level node of the ffmpeg output AST
  91. """
  92. ast = Node()
  93. node_stack = [ast]
  94. indent_level = 0
  95. for mem in output:
  96. # skip blank lines
  97. if len(mem.strip()) == 0:
  98. continue
  99. indent, line = get_indent(mem)
  100. node = Node(line)
  101. if indent == indent_level:
  102. node_stack[-1].add_node(node)
  103. elif indent > indent_level:
  104. node_stack.append(node_stack[-1].children[-1])
  105. indent_level = indent
  106. node_stack[-1].add_node(node)
  107. else:
  108. for dedent in range(indent, indent_level, 2):
  109. # make sure we never pop everything off the stack.
  110. # the root should always be on the stack.
  111. if len(node_stack) <= 1:
  112. break
  113. node_stack.pop()
  114. indent_level = indent
  115. node_stack[-1].add_node(node)
  116. return ast
  117. # there's always a space before the size and either a space or a comma
  118. # afterwards.
  119. SIZE_RE = re.compile(" (\\d+)x(\\d+)[ ,]")
  120. def extract_info(ast):
  121. info = {}
  122. # logging.info("get_media_info: %s", ast.pformat())
  123. input0 = ast.get_by_key("Input #0")
  124. if not input0:
  125. raise ValueError("no input #0")
  126. foo, info['container'], bar = input0.line.split(', ', 2)
  127. if ',' in info['container']:
  128. info['container'] = info['container'].split(',')
  129. metadata = input0.get_by_key("Metadata")
  130. if metadata:
  131. for key in ('title', 'artist', 'album', 'track', 'genre'):
  132. node = metadata.get_by_key(key)
  133. if node:
  134. info[key] = node.line.split(':', 1)[1].strip()
  135. major_brand_node = metadata.get_by_key("major_brand")
  136. extra_container_types = []
  137. if major_brand_node:
  138. major_brand = major_brand_node.line.split(':')[1].strip()
  139. extra_container_types = [major_brand]
  140. else:
  141. major_brand = None
  142. compatible_brands_node = metadata.get_by_key("compatible_brands")
  143. if compatible_brands_node:
  144. line = compatible_brands_node.line.split(':')[1].strip()
  145. extra_container_types.extend(line[i:i + 4]
  146. for i in range(0, len(line), 4)
  147. if line[i:i + 4] != major_brand)
  148. if extra_container_types:
  149. if not isinstance(info['container'], list):
  150. info['container'] = [info['container']]
  151. info['container'].extend(extra_container_types)
  152. duration = input0.get_by_key("Duration:")
  153. if duration:
  154. _, rest = duration.line.split(':', 1)
  155. duration_string, _ = rest.split(', ', 1)
  156. logging.info("duration: %r", duration_string)
  157. try:
  158. hours, minutes, seconds = [
  159. float(i) for i in duration_string.split(':')]
  160. except ValueError:
  161. if duration_string.strip() != "N/A":
  162. logging.warn("Error parsing duration string: %r",
  163. duration_string)
  164. else:
  165. info['duration'] = hms_to_seconds(hours, minutes, seconds)
  166. for stream_node in duration.children:
  167. stream = stream_node.line
  168. if "Video:" in stream:
  169. stream_number, video, data = stream.split(': ', 2)
  170. video_codec = data.split(', ')[0]
  171. if ' ' in video_codec:
  172. video_codec, drmp = video_codec.split(' ', 1)
  173. if 'drm' in drmp:
  174. info.setdefault('has_drm', []).append('video')
  175. info['video_codec'] = video_codec
  176. match = SIZE_RE.search(data)
  177. if match:
  178. info["width"] = int(match.group(1))
  179. info["height"] = int(match.group(2))
  180. elif 'Audio:' in stream:
  181. stream_number, video, data = stream.split(': ', 2)
  182. audio_codec = data.split(', ')[0]
  183. if ' ' in audio_codec:
  184. audio_codec, drmp = audio_codec.split(' ', 1)
  185. if 'drm' in drmp:
  186. info.setdefault('has_drm', []).append('audio')
  187. info['audio_codec'] = audio_codec
  188. return info
  189. def get_ffmpeg_output(filepath):
  190. commandline = [get_ffmpeg_executable_path(),
  191. "-i", convert_path_for_subprocess(filepath)]
  192. logging.info("get_ffmpeg_output(): running %s", commandline)
  193. try:
  194. output = execute.check_output(commandline)
  195. except execute.CalledProcessError as e:
  196. if e.returncode != 1:
  197. logger.exception("error calling %r\noutput:%s", commandline,
  198. e.output)
  199. # ffmpeg -i generally returns 1, so we ignore the exception and
  200. # just get the output.
  201. output = e.output
  202. return output
  203. def get_media_info(filepath):
  204. """Takes a file path and returns a dict of information about
  205. this media file that it extracted from ffmpeg -i.
  206. :param filepath: absolute path to the media file in question
  207. :returns: dict of media info possibly containing: height, width,
  208. container, audio_codec, video_codec
  209. """
  210. logger.info('get_media_info: %r', filepath)
  211. output = get_ffmpeg_output(filepath)
  212. ast = parse_ffmpeg_output(output.splitlines())
  213. info = extract_info(ast)
  214. logger.info('get_media_info: %r', info)
  215. return info
  216. def get_thumbnail(filename, width, height, output, completion, skip=0):
  217. name = 'Thumbnail - %r @ %sx%s' % (filename, width, height)
  218. def run():
  219. rv = get_thumbnail_synchronous(filename, width, height, output, skip)
  220. idle_add(lambda: completion(rv))
  221. t = threading.Thread(target=run, name=name)
  222. t.start()
  223. def get_thumbnail_synchronous(filename, width, height, output, skip=0):
  224. executable = get_ffmpeg_executable_path()
  225. filter_ = 'scale=%i:%i' % (width, height)
  226. # bz19571: temporary disable: libav ffmpeg does not support this filter
  227. # if 'ffmpeg' in executable:
  228. # # supports the thumbnail filter, we hope
  229. # filter_ = 'thumbnail,' + filter_
  230. commandline = [
  231. executable,
  232. '-ss', str(skip),
  233. '-i', convert_path_for_subprocess(filename),
  234. '-vf', filter_, '-vframes', '1', output
  235. ]
  236. try:
  237. execute.check_output(commandline)
  238. except execute.CalledProcessError as e:
  239. logger.exception('error calling %r\ncode:%s\noutput:%s',
  240. commandline, e.returncode, e.output)
  241. return None
  242. else:
  243. return output