forge.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. from datetime import datetime, timedelta
  2. from time import sleep
  3. import os
  4. from subprocess import Popen
  5. import logging
  6. from .config_manager import get_config
  7. def get_timefile_exact(time):
  8. """
  9. time is of type `datetime`; it is not "rounded" to match the real file;
  10. that work is done in get_timefile(time)
  11. """
  12. return os.path.join(
  13. get_config()["AUDIO_INPUT"], time.strftime(get_config()["AUDIO_INPUT_FORMAT"])
  14. )
  15. def round_timefile(exact):
  16. """
  17. This will round the datetime, so to match the file organization structure
  18. """
  19. return datetime(exact.year, exact.month, exact.day, exact.hour)
  20. def get_timefile(exact):
  21. return get_timefile_exact(round_timefile(exact))
  22. def get_files_and_intervals(start, end, rounder=round_timefile):
  23. """
  24. both arguments are datetime objects
  25. returns an iterator whose elements are (filename, start_cut, end_cut)
  26. Cuts are expressed in seconds
  27. """
  28. if end <= start:
  29. raise ValueError("end < start!")
  30. while start <= end:
  31. begin = rounder(start)
  32. start_cut = (start - begin).total_seconds()
  33. if end < begin + timedelta(seconds=3599):
  34. end_cut = (begin + timedelta(seconds=3599) - end).total_seconds()
  35. else:
  36. end_cut = 0
  37. yield (begin, start_cut, end_cut)
  38. start = begin + timedelta(hours=1)
  39. def mp3_join(named_intervals):
  40. """
  41. Note that these are NOT the intervals returned by get_files_and_intervals,
  42. as they do not supply a filename, but only a datetime.
  43. What we want in input is basically the same thing, but with get_timefile()
  44. applied on the first element
  45. This function make the (quite usual) assumption that the only start_cut (if
  46. any) is at the first file, and the last one is at the last file
  47. """
  48. ffmpeg = get_config()["FFMPEG_PATH"]
  49. startskip = None
  50. endskip = None
  51. files = []
  52. for (filename, start_cut, end_cut) in named_intervals:
  53. # this happens only one time, and only at the first iteration
  54. if start_cut:
  55. assert startskip is None
  56. startskip = start_cut
  57. # this happens only one time, and only at the first iteration
  58. if end_cut:
  59. assert endskip is None
  60. endskip = end_cut
  61. assert "|" not in filename
  62. files.append(filename)
  63. cmdline = [ffmpeg, "-i", "concat:%s" % "|".join(files)]
  64. cmdline += get_config()["FFMPEG_OUT_CODEC"]
  65. if startskip is not None:
  66. cmdline += ["-ss", str(startskip)]
  67. else:
  68. startskip = 0
  69. if endskip is not None:
  70. cmdline += ["-t", str(len(files) * 3600 - (startskip + endskip))]
  71. return cmdline
  72. def create_mp3(start, end, outfile, options={}, **kwargs):
  73. intervals = [
  74. (get_timefile(begin), start_cut, end_cut)
  75. for begin, start_cut, end_cut in get_files_and_intervals(start, end)
  76. ]
  77. if os.path.exists(outfile):
  78. raise OSError("file '%s' already exists" % outfile)
  79. for path, _s, _e in intervals:
  80. if not os.path.exists(path):
  81. raise OSError("file '%s' does not exist; recording system broken?" % path)
  82. # metadata date/time formatted according to
  83. # https://wiki.xiph.org/VorbisComment#Date_and_time
  84. metadata = {}
  85. if outfile.endswith(".mp3"):
  86. metadata["TRDC"] = start.replace(microsecond=0).isoformat()
  87. metadata["RECORDINGTIME"] = metadata["TRDC"]
  88. metadata["ENCODINGTIME"] = datetime.now().replace(microsecond=0).isoformat()
  89. else:
  90. metadata["DATE"] = start.replace(microsecond=0).isoformat()
  91. metadata["ENCODER"] = "https://github.com/boyska/techrec"
  92. if "title" in options:
  93. metadata["TITLE"] = options["title"]
  94. if options.get("license_uri", None) is not None:
  95. metadata["RIGHTS-DATE"] = start.strftime("%Y-%m")
  96. metadata["RIGHTS-URI"] = options["license_uri"]
  97. if "extra_tags" in options:
  98. metadata.update(options["extra_tags"])
  99. metadata_list = []
  100. for tag, value in metadata.items():
  101. if "=" in tag:
  102. logging.error('Received a tag with "=" inside, skipping')
  103. continue
  104. metadata_list.append("-metadata")
  105. metadata_list.append("%s=%s" % (tag, value))
  106. p = Popen(
  107. mp3_join(intervals) + metadata_list + get_config()["FFMPEG_OPTIONS"] + [outfile]
  108. )
  109. if get_config()["FORGE_TIMEOUT"] == 0:
  110. p.wait()
  111. else:
  112. start = datetime.now()
  113. while (datetime.now() - start).total_seconds() < get_config()["FORGE_TIMEOUT"]:
  114. p.poll()
  115. if p.returncode is None:
  116. sleep(1)
  117. else:
  118. break
  119. if p.returncode is None:
  120. os.kill(p.pid, 15)
  121. try:
  122. os.remove(outfile)
  123. except:
  124. pass
  125. raise Exception("timeout") # TODO: make a specific TimeoutError
  126. if p.returncode != 0:
  127. raise OSError("return code was %d" % p.returncode)
  128. return True
  129. def main_cmd(options):
  130. log = logging.getLogger("forge_main")
  131. outfile = os.path.abspath(os.path.join(options.cwd, options.outfile))
  132. log.debug("will forge an mp3 into %s" % (outfile))
  133. create_mp3(options.starttime, options.endtime, outfile)