PageRenderTime 93ms CodeModel.GetById 61ms RepoModel.GetById 0ms app.codeStats 0ms

/encoder.py

https://gitlab.com/mjcarson/Auto-Encode
Python | 355 lines | 299 code | 17 blank | 39 comment | 5 complexity | 835d32adc8dd813b2ca1240d30bcc0a3 MD5 | raw file
  1. #!/usr/bin/env python3
  2. '''Encodes files for Auto-Encode and returns them for insertion'''
  3. import os
  4. import re
  5. import json
  6. import time
  7. import yaml
  8. import logging
  9. import logging.handlers
  10. import pexpect
  11. import argparse
  12. import platform
  13. import subprocess
  14. from threading import Thread, Lock
  15. # AE api client
  16. import capi.capi as capi
  17. # AE utlities
  18. import utils.ldir as ldir
  19. import utils.utils as utils
  20. class Encoder(object):
  21. '''Encoder for Auto-Encode'''
  22. def __init__(self, cfg_path="confs/ae.yaml", log=None):
  23. '''
  24. inits Encoder
  25. Args:
  26. cfg_path (string): path to config file
  27. log (logging object): logging object
  28. '''
  29. # load yaml
  30. #with open("/Auto-Encode/confs/ae.yaml") as fp:
  31. with open(cfg_path) as fp:
  32. self.fconfig = yaml.load(fp, Loader=yaml.FullLoader)
  33. # extract capi config
  34. self.config = self.fconfig["encoder"]
  35. # setup logging
  36. if log is not None:
  37. self.log = log
  38. else:
  39. # ensure log path exists
  40. parent = os.path.dirname(self.config["log"]["path"])
  41. os.makedirs(parent, exist_ok=True)
  42. # setup logging
  43. self.log = logging.getLogger(platform.node())
  44. self.log.setLevel(logging.DEBUG)
  45. # create console handler
  46. console = logging.StreamHandler()
  47. console.setLevel(logging.DEBUG)
  48. # create a file handler
  49. fhandler = logging.handlers.TimedRotatingFileHandler(\
  50. self.config["log"]["path"], \
  51. when='D', \
  52. interval=1, \
  53. backupCount=3)
  54. fhandler.setLevel(self.config["log"]["level"].upper())
  55. # create a logging format
  56. formatter = logging.Formatter('%(asctime)s [%(name)s] [%(processName)s] [%(levelname)s] %(message)s')
  57. fhandler.setFormatter(formatter)
  58. console.setFormatter(formatter)
  59. # add the handlers to the logger
  60. self.log.addHandler(fhandler)
  61. # use console_log
  62. if self.config["log"]["console"] == True:
  63. self.log.addHandler(console)
  64. # api client
  65. self.capi = capi.Capi(cfg_path, self.log)
  66. # ae utilities
  67. self.utils = utils.Utils(self.config, self.log, self.capi)
  68. # setup vars
  69. self.node = platform.node()
  70. self.threads = []
  71. # get all wdir data
  72. self.wdirs = []
  73. for wdir in self.fconfig["watch_dirs"]:
  74. self.log.info("Adding job type {}".format(wdir["name"]))
  75. dir_obj = ldir.Load_dir(wdir["name"], \
  76. wdir["load_dir"], \
  77. wdir["targ_dir"], \
  78. wdir["share_root"], \
  79. wdir["presets"], \
  80. wdir["type"], \
  81. wdir["staging_root"], \
  82. wdir["container"])
  83. self.wdirs.append(dir_obj)
  84. # log spam protection
  85. self.spam = []
  86. def _download(self, jdata, src, dest):
  87. '''
  88. downloads media to worker assuming you have a share mounted from
  89. cifs/nfs/samba/gluster/ceph
  90. Args:
  91. jdata (dict): job data
  92. src (string): path to copy file from
  93. dest (string): path to copy file to
  94. Returns:
  95. bool: True if download succeded, False if it didn't
  96. '''
  97. # set job status
  98. self.capi.set_job_status(jdata["job_id"], "downloading")
  99. # copy file
  100. return self.utils.copy(src, dest, jdata["job_id"])
  101. def _upload(self, jdata, src, dest):
  102. '''
  103. uploads media to share assuming you have a share mounted from
  104. cifs/nfs/samba/gluster/ceph
  105. Args:
  106. jdata (dict): job data
  107. src (string): path to copy file from
  108. dest (string): path to copy file to
  109. '''
  110. # set job status
  111. self.capi.set_job_status(jdata["job_id"], "uploading")
  112. # copy file
  113. while 1:
  114. ustatus = self.utils.copy(src, dest, jdata["job_id"])
  115. # check if upload was successfull
  116. if ustatus == True:
  117. # set job status
  118. self.capi.set_job_status(jdata["job_id"], "uploaded")
  119. break
  120. # upload failed
  121. time.sleep(60)
  122. def _hdr_scan(self, path):
  123. '''detects if a video is 4k'''
  124. out = subprocess.check_output("mediainfo \"" + path + "\"", shell=True)
  125. if ": BT.2020" in str(out):
  126. self.log.info("HDR detected injecting HDR preset")
  127. return '-x265-params "level=5.2:colorprim=bt2020:colormatrix=bt2020nc:transfer=smpte2084"'
  128. else:
  129. return ""
  130. def _extra_preset_scan(self, path):
  131. '''
  132. scans and sets extra presets
  133. Args:
  134. path (string): path to file to scan
  135. Returns:
  136. string: extra presets to set
  137. '''
  138. expresets = " "
  139. # list of scan results
  140. scanres = []
  141. # hdr presets
  142. scanres.append(self._hdr_scan(path))
  143. # combine all scans into list of extra presets
  144. return expresets.join(scanres)
  145. def _calc_secs(self, time):
  146. '''
  147. gets the seconds from a ffmpeg timestamp
  148. Args:
  149. time (string): ffmpeg timestamp
  150. Returns:
  151. int: seconds from ffmpeg timestamp
  152. '''
  153. subtime_re = re.compile("\d\d")
  154. t_groups = subtime_re.findall(time)
  155. total_s = 0
  156. # count num of seconds
  157. h_s = int(t_groups[0]) * 60 * 60
  158. m_s = int(t_groups[1]) * 60
  159. s = int(t_groups[2])
  160. total_s = h_s + m_s + s + int(t_groups[3])/100
  161. return total_s
  162. def _transcode(self, src, dest, presets, jdata, expresets=True):
  163. '''
  164. transcodes codes a file using ffmpeg
  165. Args:
  166. src (string): path to file to transcode
  167. dest (string): destination to place transcoded file
  168. presets (string): presets to to use
  169. expresets (bool, optional): whether to scan for extra presets
  170. defaults to True
  171. Retuns:
  172. bool: True if successful, False if not
  173. '''
  174. # scan for extra presets to set
  175. if expresets == True:
  176. presets = presets + " " + self._extra_preset_scan(src)
  177. print(presets)
  178. # build ffmpeg command
  179. ffcmd = 'ffmpeg -i "{}" {} "{}"'.format(src, \
  180. presets, \
  181. dest)
  182. self.log.debug(ffcmd)
  183. # start transcode job
  184. proc = pexpect.spawn(ffcmd)
  185. status = proc.compile_pattern_list([
  186. pexpect.EOF,
  187. "frame=(.)*"])
  188. self.log.info("Started encoding " + jdata["name"])
  189. self.capi.set_job_status(jdata["job_id"], "converting")
  190. # update job progress
  191. time_re = re.compile("time=(\d)*:(\d)*:(\d)*.(\d)*")
  192. spam_prot = 60
  193. while 1:
  194. i = proc.expect_list(status, timeout=None)
  195. if i == 0:
  196. break
  197. if i == 1:
  198. # get current transcoded time from ffmpeg
  199. line = proc.match.group(0)
  200. line = line.decode("utf-8")
  201. # log once every 60 attempts to keep log spam down
  202. if spam_prot == 60:
  203. self.log.info("%s", line.rstrip())
  204. spam_prot = 0
  205. else:
  206. spam_prot += 1
  207. t_groups = time_re.search(line)
  208. # make sure a time was actually pulled
  209. if t_groups is not None:
  210. total_s = self._calc_secs(t_groups[0])
  211. # update mongodb
  212. self.capi.set_job_transcoded_time(jdata["job_id"], total_s)
  213. prog = str((total_s / int(jdata["total_time"])) * 100).split(".")[0]
  214. self.capi.set_job_progress(jdata["job_id"], prog)
  215. continue
  216. # job ended
  217. proc.close()
  218. # update job status if we errored out
  219. if proc.exitstatus != 0:
  220. # job errored out
  221. self.log.error("ffmpeg ran into a problem")
  222. self.capi.set_job_status(jdata["job_id"], "error")
  223. return False
  224. return True
  225. def worker(self, wdir):
  226. '''
  227. Completes one Auto-Encode job
  228. Args:
  229. wdir (dict): load directory config
  230. Returns:
  231. bool: True if job completed, False if no job
  232. '''
  233. # get job
  234. try:
  235. jdata = self.capi.get_job(jtype=wdir.name)
  236. # return False if we dont have a job
  237. if jdata is None:
  238. return False
  239. # set jobs worker
  240. self.capi.set_job_worker(jdata["job_id"], self.node)
  241. # get name with new extension
  242. ncname = self.utils.get_ncname(jdata["name"], wdir)
  243. # build paths
  244. local_tmp = os.path.join(self.config["local"]["tmp"], jdata["name"])
  245. local_staging = os.path.join(self.config["local"]["staging"], \
  246. ncname)
  247. server_tmp = os.path.join(self.config["share"]["tmp"], \
  248. jdata["path"])
  249. server_staging = os.path.join(self.config["share"]["staging"], \
  250. jdata["staging_path"])
  251. # download media
  252. if self._download(jdata, server_tmp, local_tmp) == False:
  253. # failed to download job
  254. self.capi.set_job_status(jdata["job_id"], "recieved")
  255. return False
  256. # pause before transcode to let updates_dl finish
  257. time.sleep(10)
  258. # transcode file
  259. if self._transcode(local_tmp, local_staging, wdir.presets, jdata) == False:
  260. # failed to transcode job
  261. self.capi.set_job_status(jdata["job_id"], "error")
  262. return False
  263. # copy transcoded file to share
  264. self._upload(jdata, local_staging, server_staging)
  265. # pause before removal
  266. time.sleep(30)
  267. # remove local tmp/staging
  268. os.remove(local_tmp)
  269. os.remove(local_staging)
  270. return True
  271. except KeyboardInterrupt:
  272. self.log.info("CTRL^C detected")
  273. self.log.info("Current job being reset to recieved")
  274. self.capi.set_job_status(jdata["job_id"], "recieved")
  275. exit(1)
  276. def start(self):
  277. '''Start encoder'''
  278. # enter main work loop
  279. self.log.info("Encoder started")
  280. while 1:
  281. # check if cluster is shutting down
  282. if self.capi.get_cstatus() == "shutdown":
  283. self.log.info("Shutting down")
  284. break
  285. # start working on jobs
  286. for wdir in self.wdirs:
  287. # get one job if one exists and complete it
  288. if self.worker(wdir):
  289. # if we completed a job check for cluster shutdown
  290. break
  291. time.sleep(10)
  292. def argparser():
  293. '''parses arguments for loader'''
  294. parser = argparse.ArgumentParser(description=\
  295. "Loads files into Auto-Encode")
  296. parser.add_argument('--conf', help="path to config file")
  297. return parser.parse_args()
  298. if __name__ == "__main__":
  299. args = argparser()
  300. enc = Encoder(args.conf)
  301. enc.start()