sermon.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. # Copyright © 2024 Noah Vogt <noah@noahvogt.com>
  2. # This program is free software: you can redistribute it and/or modify
  3. # it under the terms of the GNU General Public License as published by
  4. # the Free Software Foundation, either version 3 of the License, or
  5. # (at your option) any later version.
  6. # This program is distributed in the hope that it will be useful,
  7. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  8. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  9. # GNU General Public License for more details.
  10. # You should have received a copy of the GNU General Public License
  11. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  12. import sys
  13. from os import path, listdir
  14. from shlex import split
  15. from re import match
  16. from subprocess import Popen
  17. import ftplib
  18. from shutil import copy2
  19. from PyQt5.QtWidgets import ( # pylint: disable=no-name-in-module
  20. QApplication,
  21. QMessageBox,
  22. QInputDialog,
  23. )
  24. from utils import CustomException
  25. from input import InfoMsgBox
  26. from audio import (
  27. get_ffmpeg_timestamp_from_frame,
  28. SermonSegment,
  29. get_wave_duration_in_frames,
  30. get_index_line_as_frames,
  31. )
  32. import config as const
  33. from .verify import (
  34. get_padded_cd_num_from_sheet_filename,
  35. is_legal_sheet_filename,
  36. )
  37. def get_full_wav_path(segment: SermonSegment) -> str:
  38. try:
  39. with open(
  40. segment.source_cue_sheet,
  41. mode="r",
  42. encoding="utf-8-sig",
  43. ) as cue_sheet_reader:
  44. cue_sheet_content = cue_sheet_reader.readlines()
  45. first_line = cue_sheet_content[0].strip()
  46. if not match(r"^FILE \".+\" WAVE$", first_line):
  47. raise CustomException("invalid first cue sheet line")
  48. full_wav_path = first_line[first_line.find('"') + 1 :]
  49. return full_wav_path[: full_wav_path.rfind('"')]
  50. except (
  51. FileNotFoundError,
  52. PermissionError,
  53. IOError,
  54. CustomException,
  55. ) as error:
  56. app = QApplication([])
  57. QMessageBox.critical(
  58. None,
  59. "Error",
  60. f"Could not parse cue sheet: '{segment.source_cue_sheet}',"
  61. + f"Reason: {error}",
  62. )
  63. del app
  64. sys.exit(1)
  65. def get_audio_base_path_from_segment(segment: SermonSegment) -> str:
  66. splitted_sheet_path = path.split(segment.source_cue_sheet)
  67. yyyy_mm_dd = path.split(splitted_sheet_path[0])[1]
  68. cd_num = get_padded_cd_num_from_sheet_filename(splitted_sheet_path[1])
  69. mp3_path = path.join(
  70. splitted_sheet_path[0],
  71. f"{yyyy_mm_dd}-{cd_num}-segment-{segment.source_marker}",
  72. )
  73. return mp3_path
  74. def make_sermon_segment_mp3(segment: SermonSegment) -> str:
  75. full_wav_path = get_full_wav_path(segment)
  76. mp3_path = f"{get_audio_base_path_from_segment(segment)}.mp3"
  77. cmd = "ffmpeg -y -i {} -acodec libmp3lame {}".format(
  78. full_wav_path,
  79. mp3_path,
  80. )
  81. process = Popen(split(cmd))
  82. _ = process.communicate()[0] # wait for subprocess to end
  83. if process.returncode not in [255, 0]:
  84. app = QApplication([])
  85. InfoMsgBox(
  86. QMessageBox.Critical,
  87. "Error",
  88. "ffmpeg terminated with " + f"exit code {process.returncode}",
  89. )
  90. del app
  91. return mp3_path
  92. def prepare_audio_files_for_segment_chooser(
  93. segments: list[SermonSegment],
  94. ) -> None:
  95. for segment in segments:
  96. # TODO: check if file duration and type roughly match the target to
  97. # avoid useless regenerating. Also, parallelization.
  98. cmd = (
  99. f"ffmpeg -y -i {get_full_wav_path(segment)} -ss "
  100. + f" {get_ffmpeg_timestamp_from_frame(segment.start_frame)} "
  101. + f"-to {get_ffmpeg_timestamp_from_frame(segment.end_frame)} "
  102. + f"-acodec copy {get_audio_base_path_from_segment(segment)}.wav"
  103. )
  104. process = Popen(split(cmd))
  105. _ = process.communicate()[0] # wait for subprocess to end
  106. if process.returncode not in [255, 0]:
  107. app = QApplication([])
  108. InfoMsgBox(
  109. QMessageBox.Critical,
  110. "Error",
  111. "ffmpeg terminated with " + f"exit code {process.returncode}",
  112. )
  113. del app
  114. sys.exit(1)
  115. def get_possible_sermon_segments_of_day(yyyy_mm_dd: str) -> list[SermonSegment]:
  116. try:
  117. segments = []
  118. base_frames = 0
  119. max_frames = 0
  120. day_dir = path.join(const.CD_RECORD_OUTPUT_BASEDIR, yyyy_mm_dd)
  121. files = sorted(listdir(day_dir))
  122. cue_sheets = []
  123. for file in files:
  124. if is_legal_sheet_filename(file):
  125. cue_sheets.append(file)
  126. for sheet_num, sheet in enumerate(cue_sheets):
  127. with open(
  128. path.join(day_dir, sheet),
  129. mode="r",
  130. encoding="utf-8-sig",
  131. ) as sheet_reader:
  132. sheet_content = sheet_reader.readlines()
  133. start_frame = 0
  134. end_frame = 0
  135. wav_path = ""
  136. max_line_num = 0
  137. for line_num, line in enumerate(sheet_content):
  138. max_line_num = line_num
  139. if line_num == 0:
  140. if not match(r"^FILE \".+\" WAVE$", line):
  141. raise CustomException("invalid first cue sheet line")
  142. wav_path = line[line.find('"') + 1 :]
  143. wav_path = wav_path[: wav_path.rfind('"')]
  144. elif match(r"^\s+INDEX 01 ([0-9]{2}:){2}[0-9]{2}\s*$", line):
  145. if line_num != 2:
  146. end_frame = get_index_line_as_frames(line)
  147. segments.append(
  148. SermonSegment(
  149. start_frame,
  150. end_frame,
  151. path.join(day_dir, sheet),
  152. (max_line_num - 2) // 2,
  153. )
  154. )
  155. start_frame = end_frame
  156. segments.append(
  157. SermonSegment(
  158. start_frame,
  159. get_wave_duration_in_frames(wav_path),
  160. path.join(day_dir, sheet),
  161. max_line_num // 2,
  162. )
  163. )
  164. return segments
  165. except (
  166. FileNotFoundError,
  167. PermissionError,
  168. IOError,
  169. CustomException,
  170. ) as error:
  171. InfoMsgBox(
  172. QMessageBox.Critical,
  173. "Error",
  174. f"Error: Could not parse sermon segments. Reason: {error}",
  175. )
  176. sys.exit(1)
  177. def get_segments_with_suitable_time(
  178. segments: list[SermonSegment],
  179. ) -> list[SermonSegment]:
  180. suitable_segments = []
  181. for segment in segments:
  182. if (
  183. segment.end_frame - segment.start_frame
  184. >= const.SERMON_UPLOAD_SUITABLE_SEGMENT_FRAMES
  185. ):
  186. # if segment.end_frame - segment.start_frame >= 90000: # 75 * 60 * 20
  187. suitable_segments.append(segment)
  188. return suitable_segments
  189. def upload_sermon_segment(segment: SermonSegment) -> None:
  190. try:
  191. session = ftplib.FTP_TLS(
  192. const.SERMON_UPLOAD_FTP_HOSTNAME,
  193. const.SERMON_UPLOAD_FTP_USER,
  194. const.SERMON_UPLOAD_FTP_PASSWORD,
  195. )
  196. session.cwd(const.SERMON_UPLOAD_FTP_UPLOAD_DIR)
  197. raw_filenames = session.nlst()
  198. disallowed_filenames = []
  199. for filename in raw_filenames:
  200. if filename not in (".", ".."):
  201. disallowed_filenames.append(filename)
  202. app = QApplication([])
  203. wanted_filename, accepted_dialog = QInputDialog.getText(
  204. None,
  205. "Input Dialog",
  206. "Enter the filename for the Sermon (the .mp3 can be omitted):",
  207. )
  208. del app
  209. if not wanted_filename.endswith(".mp3"):
  210. wanted_filename = wanted_filename + ".mp3"
  211. if not accepted_dialog or wanted_filename == ".mp3":
  212. session.quit()
  213. sys.exit(0)
  214. if wanted_filename in disallowed_filenames:
  215. InfoMsgBox(
  216. QMessageBox.Critical, "Error", "Error: filename already exists."
  217. )
  218. session.quit()
  219. sys.exit(1)
  220. orig_mp3 = make_sermon_segment_mp3(segment)
  221. mp3_final_path = path.join(path.split(orig_mp3)[0], wanted_filename)
  222. copy2(orig_mp3, mp3_final_path)
  223. with open(mp3_final_path, "rb") as file:
  224. session.storbinary(f"STOR {path.split(mp3_final_path)[1]}", file)
  225. session.quit()
  226. InfoMsgBox(
  227. QMessageBox.Information, "Success", "Sermon uploaded successfully."
  228. )
  229. except (
  230. *ftplib.all_errors,
  231. FileNotFoundError,
  232. PermissionError,
  233. IOError,
  234. ) as error:
  235. InfoMsgBox(
  236. QMessageBox.Critical,
  237. "Error",
  238. f"Error: Could not connect to ftp server. Reason: {error}",
  239. )
  240. sys.exit(1)
  241. def upload_sermon_for_day(yyyy_mm_dd: str):
  242. segments = get_possible_sermon_segments_of_day(yyyy_mm_dd)
  243. if not segments:
  244. InfoMsgBox(
  245. QMessageBox.Critical,
  246. "Error",
  247. f"Error: No segment for day '{yyyy_mm_dd}' found",
  248. )
  249. sys.exit(1)
  250. suitable_segments = get_segments_with_suitable_time(segments)
  251. # TODO: remove
  252. # for segment in suitable_segments:
  253. # print(f"start {segment.start_frame}")
  254. # print(f"end {segment.end_frame}")
  255. # print(f"sheet {segment.source_cue_sheet}")
  256. # print(f"marker {segment.source_marker}")
  257. if not suitable_segments:
  258. # TODO: choose
  259. prepare_audio_files_for_segment_chooser(segments)
  260. InfoMsgBox(
  261. QMessageBox.Critical, "Error", "Error: no suitable segment found"
  262. )
  263. elif len(suitable_segments) == 1:
  264. upload_sermon_segment(suitable_segments[0])
  265. else:
  266. # TODO: choose
  267. pass