forge.py 5.2 KB

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