__init__.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. # GNU MediaGoblin -- federated, autonomous media hosting
  2. # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
  3. #
  4. # This program is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU Affero General Public License as published by
  6. # the Free Software Foundation, either version 3 of the License, or
  7. # (at your option) any later version.
  8. #
  9. # This program is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. # GNU Affero General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU Affero General Public License
  15. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  16. # Use an ordered dict if we can. If not, we'll just use a normal dict
  17. # later.
  18. try:
  19. from collections import OrderedDict
  20. except:
  21. OrderedDict = None
  22. import logging
  23. import os
  24. import six
  25. from mediagoblin import mg_globals as mgg
  26. from mediagoblin.db.util import atomic_update
  27. from mediagoblin.db.models import MediaEntry
  28. from mediagoblin.tools.pluginapi import hook_handle
  29. from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
  30. _log = logging.getLogger(__name__)
  31. class ProgressCallback(object):
  32. def __init__(self, entry):
  33. self.entry = entry
  34. def __call__(self, progress):
  35. if progress:
  36. self.entry.transcoding_progress = progress
  37. self.entry.save()
  38. def create_pub_filepath(entry, filename):
  39. return mgg.public_store.get_unique_filepath(
  40. ['media_entries',
  41. six.text_type(entry.id),
  42. filename])
  43. class FilenameBuilder(object):
  44. """Easily slice and dice filenames.
  45. Initialize this class with an original file path, then use the fill()
  46. method to create new filenames based on the original.
  47. """
  48. MAX_FILENAME_LENGTH = 255 # VFAT's maximum filename length
  49. def __init__(self, path):
  50. """Initialize a builder from an original file path."""
  51. self.dirpath, self.basename = os.path.split(path)
  52. self.basename, self.ext = os.path.splitext(self.basename)
  53. self.ext = self.ext.lower()
  54. def fill(self, fmtstr):
  55. """Build a new filename based on the original.
  56. The fmtstr argument can include the following:
  57. {basename} -- the original basename, with the extension removed
  58. {ext} -- the original extension, always lowercase
  59. If necessary, {basename} will be truncated so the filename does not
  60. exceed this class' MAX_FILENAME_LENGTH in length.
  61. """
  62. basename_len = (self.MAX_FILENAME_LENGTH -
  63. len(fmtstr.format(basename='', ext=self.ext)))
  64. return fmtstr.format(basename=self.basename[:basename_len],
  65. ext=self.ext)
  66. class MediaProcessor(object):
  67. """A particular processor for this media type.
  68. While the ProcessingManager handles all types of MediaProcessing
  69. possible for a particular media type, a MediaProcessor can be
  70. thought of as a *particular* processing action for a media type.
  71. For example, you may have separate MediaProcessors for:
  72. - initial_processing: the intial processing of a media
  73. - gen_thumb: generate a thumbnail
  74. - resize: resize an image
  75. - transcode: transcode a video
  76. ... etc.
  77. Some information on producing a new MediaProcessor for your media type:
  78. - You *must* supply a name attribute. This must be a class level
  79. attribute, and a string. This will be used to determine the
  80. subcommand of your process
  81. - It's recommended that you supply a class level description
  82. attribute.
  83. - Supply a media_is_eligible classmethod. This will be used to
  84. determine whether or not a media entry is eligible to use this
  85. processor type. See the method documentation for details.
  86. - To give "./bin/gmg reprocess run" abilities to this media type,
  87. supply both gnerate_parser and parser_to_request classmethods.
  88. - The process method will be what actually processes your media.
  89. """
  90. # You MUST override this in the child MediaProcessor!
  91. name = None
  92. # Optional, but will be used in various places to describe the
  93. # action this MediaProcessor provides
  94. description = None
  95. def __init__(self, manager, entry):
  96. self.manager = manager
  97. self.entry = entry
  98. self.entry_orig_state = entry.state
  99. # Should be initialized at time of processing, at least
  100. self.workbench = None
  101. def __enter__(self):
  102. self.workbench = mgg.workbench_manager.create()
  103. return self
  104. def __exit__(self, *args):
  105. self.workbench.destroy()
  106. self.workbench = None
  107. # @with_workbench
  108. def process(self, **kwargs):
  109. """
  110. Actually process this media entry.
  111. """
  112. raise NotImplementedError
  113. @classmethod
  114. def media_is_eligible(cls, entry=None, state=None):
  115. raise NotImplementedError
  116. ###############################
  117. # Command line interface things
  118. ###############################
  119. @classmethod
  120. def generate_parser(cls):
  121. raise NotImplementedError
  122. @classmethod
  123. def args_to_request(cls, args):
  124. raise NotImplementedError
  125. ##########################################
  126. # THE FUTURE: web interface things here :)
  127. ##########################################
  128. #####################
  129. # Some common "steps"
  130. #####################
  131. def delete_queue_file(self):
  132. # Remove queued media file from storage and database.
  133. # queued_filepath is in the task_id directory which should
  134. # be removed too, but fail if the directory is not empty to be on
  135. # the super-safe side.
  136. queued_filepath = self.entry.queued_media_file
  137. if queued_filepath:
  138. mgg.queue_store.delete_file(queued_filepath) # rm file
  139. mgg.queue_store.delete_dir(queued_filepath[:-1]) # rm dir
  140. self.entry.queued_media_file = []
  141. class ProcessingKeyError(Exception): pass
  142. class ProcessorDoesNotExist(ProcessingKeyError): pass
  143. class ProcessorNotEligible(ProcessingKeyError): pass
  144. class ProcessingManagerDoesNotExist(ProcessingKeyError): pass
  145. class ProcessingManager(object):
  146. """Manages all the processing actions available for a media type
  147. Specific processing actions, MediaProcessor subclasses, are added
  148. to the ProcessingManager.
  149. """
  150. def __init__(self):
  151. # Dict of all MediaProcessors of this media type
  152. if OrderedDict is not None:
  153. self.processors = OrderedDict()
  154. else:
  155. self.processors = {}
  156. def add_processor(self, processor):
  157. """
  158. Add a processor class to this media type
  159. """
  160. name = processor.name
  161. if name is None:
  162. raise AttributeError("Processor class's .name attribute not set")
  163. self.processors[name] = processor
  164. def list_eligible_processors(self, entry):
  165. """
  166. List all processors that this media entry is eligible to be processed
  167. for.
  168. """
  169. return [
  170. processor
  171. for processor in self.processors.values()
  172. if processor.media_is_eligible(entry=entry)]
  173. def list_all_processors_by_state(self, state):
  174. """
  175. List all processors that this media state is eligible to be processed
  176. for.
  177. """
  178. return [
  179. processor
  180. for processor in self.processors.values()
  181. if processor.media_is_eligible(state=state)]
  182. def list_all_processors(self):
  183. return self.processors.values()
  184. def gen_process_request_via_cli(self, subparser):
  185. # Got to figure out what actually goes here before I can write this properly
  186. pass
  187. def get_processor(self, key, entry=None):
  188. """
  189. Get the processor with this key.
  190. If entry supplied, make sure this entry is actually compatible;
  191. otherwise raise error.
  192. """
  193. try:
  194. processor = self.processors[key]
  195. except KeyError:
  196. raise ProcessorDoesNotExist(
  197. "'%s' processor does not exist for this media type" % key)
  198. if entry and not processor.media_is_eligible(entry):
  199. raise ProcessorNotEligible(
  200. "This entry is not eligible for processor with name '%s'" % key)
  201. return processor
  202. def request_from_args(args, which_args):
  203. """
  204. Generate a request from the values of some argparse parsed args
  205. """
  206. request = {}
  207. for arg in which_args:
  208. request[arg] = getattr(args, arg)
  209. return request
  210. class MediaEntryNotFound(Exception): pass
  211. def get_processing_manager_for_type(media_type):
  212. """
  213. Get the appropriate media manager for this type
  214. """
  215. manager_class = hook_handle(('reprocess_manager', media_type))
  216. if not manager_class:
  217. raise ProcessingManagerDoesNotExist(
  218. "A processing manager does not exist for {0}".format(media_type))
  219. manager = manager_class()
  220. return manager
  221. def get_entry_and_processing_manager(media_id):
  222. """
  223. Get a MediaEntry, its media type, and its manager all in one go.
  224. Returns a tuple of: `(entry, media_type, media_manager)`
  225. """
  226. entry = MediaEntry.query.filter_by(id=media_id).first()
  227. if entry is None:
  228. raise MediaEntryNotFound("Can't find media with id '%s'" % media_id)
  229. manager = get_processing_manager_for_type(entry.media_type)
  230. return entry, manager
  231. def mark_entry_failed(entry_id, exc):
  232. """
  233. Mark a media entry as having failed in its conversion.
  234. Uses the exception that was raised to mark more information. If
  235. the exception is a derivative of BaseProcessingFail then we can
  236. store extra information that can be useful for users telling them
  237. why their media failed to process.
  238. :param entry_id: The id of the media entry
  239. :param exc: An instance of BaseProcessingFail
  240. """
  241. # Was this a BaseProcessingFail? In other words, was this a
  242. # type of error that we know how to handle?
  243. if isinstance(exc, BaseProcessingFail):
  244. # Looks like yes, so record information about that failure and any
  245. # metadata the user might have supplied.
  246. atomic_update(mgg.database.MediaEntry,
  247. {'id': entry_id},
  248. {u'state': u'failed',
  249. u'fail_error': six.text_type(exc.exception_path),
  250. u'fail_metadata': exc.metadata})
  251. else:
  252. _log.warn("No idea what happened here, but it failed: %r", exc)
  253. # Looks like no, so just mark it as failed and don't record a
  254. # failure_error (we'll assume it wasn't handled) and don't record
  255. # metadata (in fact overwrite it if somehow it had previous info
  256. # here)
  257. atomic_update(mgg.database.MediaEntry,
  258. {'id': entry_id},
  259. {u'state': u'failed',
  260. u'fail_error': None,
  261. u'fail_metadata': {}})
  262. def get_process_filename(entry, workbench, acceptable_files):
  263. """
  264. Try and get the queued file if available, otherwise return the first file
  265. in the acceptable_files that we have.
  266. If no acceptable_files, raise ProcessFileNotFound
  267. """
  268. if entry.queued_media_file:
  269. filepath = entry.queued_media_file
  270. storage = mgg.queue_store
  271. else:
  272. for keyname in acceptable_files:
  273. if entry.media_files.get(keyname):
  274. filepath = entry.media_files[keyname]
  275. storage = mgg.public_store
  276. break
  277. if not filepath:
  278. raise ProcessFileNotFound()
  279. filename = workbench.localized_file(
  280. storage, filepath,
  281. 'source')
  282. if not os.path.exists(filename):
  283. raise ProcessFileNotFound()
  284. return filename
  285. def store_public(entry, keyname, local_file, target_name=None,
  286. delete_if_exists=True):
  287. if target_name is None:
  288. target_name = os.path.basename(local_file)
  289. target_filepath = create_pub_filepath(entry, target_name)
  290. if keyname in entry.media_files:
  291. _log.warn("store_public: keyname %r already used for file %r, "
  292. "replacing with %r", keyname,
  293. entry.media_files[keyname], target_filepath)
  294. if delete_if_exists:
  295. mgg.public_store.delete_file(entry.media_files[keyname])
  296. try:
  297. mgg.public_store.copy_local_to_storage(local_file, target_filepath)
  298. except Exception as e:
  299. _log.error(u'Exception happened: {0}'.format(e))
  300. raise PublicStoreFail(keyname=keyname)
  301. # raise an error if the file failed to copy
  302. if not mgg.public_store.file_exists(target_filepath):
  303. raise PublicStoreFail(keyname=keyname)
  304. entry.media_files[keyname] = target_filepath
  305. def copy_original(entry, orig_filename, target_name, keyname=u"original"):
  306. store_public(entry, keyname, orig_filename, target_name)
  307. class BaseProcessingFail(Exception):
  308. """
  309. Base exception that all other processing failure messages should
  310. subclass from.
  311. You shouldn't call this itself; instead you should subclass it
  312. and provide the exception_path and general_message applicable to
  313. this error.
  314. """
  315. general_message = u''
  316. @property
  317. def exception_path(self):
  318. return u"%s:%s" % (
  319. self.__class__.__module__, self.__class__.__name__)
  320. def __init__(self, **metadata):
  321. self.metadata = metadata or {}
  322. class BadMediaFail(BaseProcessingFail):
  323. """
  324. Error that should be raised when an inappropriate file was given
  325. for the media type specified.
  326. """
  327. general_message = _(u'Invalid file given for media type.')
  328. class PublicStoreFail(BaseProcessingFail):
  329. """
  330. Error that should be raised when copying to public store fails
  331. """
  332. general_message = _('Copying to public storage failed.')
  333. class ProcessFileNotFound(BaseProcessingFail):
  334. """
  335. Error that should be raised when an acceptable file for processing
  336. is not found.
  337. """
  338. general_message = _(u'An acceptable processing file was not found')