sermon.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  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 PyQt5.QtWidgets import ( # pylint: disable=no-name-in-module
  19. QApplication,
  20. QMessageBox,
  21. QInputDialog,
  22. QDialog,
  23. )
  24. from utils import CustomException, expand_dir, log, InfoMsgBox
  25. from audio import (
  26. get_ffmpeg_timestamp_from_frame,
  27. SermonSegment,
  28. get_wave_duration_in_frames,
  29. get_index_line_as_frames,
  30. AudioSourceFileType,
  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. from .gui import WaveAndSheetPreviewChooserGUI
  38. def get_full_wav_path(segment: SermonSegment) -> str:
  39. try:
  40. with open(
  41. segment.source_cue_sheet,
  42. mode="r",
  43. encoding="utf-8",
  44. ) as cue_sheet_reader:
  45. cue_sheet_content = cue_sheet_reader.readlines()
  46. first_line = cue_sheet_content[0].strip()
  47. if not match(r"^FILE \".+\" WAVE$", first_line):
  48. raise CustomException("invalid first cue sheet line")
  49. full_wav_path = first_line[first_line.find('"') + 1 :]
  50. return full_wav_path[: full_wav_path.rfind('"')]
  51. except (
  52. FileNotFoundError,
  53. PermissionError,
  54. IOError,
  55. CustomException,
  56. ) as error:
  57. app = QApplication([])
  58. QMessageBox.critical(
  59. None,
  60. "Error",
  61. f"Could not parse cue sheet: '{segment.source_cue_sheet}',"
  62. + f"Reason: {error}",
  63. )
  64. del app
  65. sys.exit(1)
  66. def get_audio_rel_path_from_segment(segment: SermonSegment) -> str:
  67. splitted_sheet_path = path.split(segment.source_cue_sheet)
  68. yyyy_mm_dd = path.split(splitted_sheet_path[0])[1]
  69. cd_num = get_padded_cd_num_from_sheet_filename(splitted_sheet_path[1])
  70. return f"{yyyy_mm_dd}-{cd_num}-segment-{segment.source_marker}"
  71. def get_audio_base_path_from_segment(segment: SermonSegment) -> str:
  72. base_path = path.split(segment.source_cue_sheet)[0]
  73. return path.join(
  74. base_path,
  75. get_audio_rel_path_from_segment(segment),
  76. )
  77. def make_sermon_mp3(source_audio: str, target_audio: str) -> None:
  78. log("Generating final mp3...")
  79. cmd = "ffmpeg -y -i \"{}\" -acodec libmp3lame \"{}\"".format(
  80. source_audio,
  81. target_audio,
  82. )
  83. process = Popen(split(cmd))
  84. _ = process.communicate()[0] # wait for subprocess to end
  85. if process.returncode not in [255, 0]:
  86. app = QApplication([])
  87. InfoMsgBox(
  88. QMessageBox.Critical,
  89. "Error",
  90. "ffmpeg terminated with " + f"exit code {process.returncode}",
  91. )
  92. del app
  93. def generate_wav_for_segment(segment: SermonSegment) -> None:
  94. cmd = (
  95. f"ffmpeg -y -i \"{get_full_wav_path(segment)}\" -ss "
  96. + f" {get_ffmpeg_timestamp_from_frame(segment.start_frame)} "
  97. + f"-to {get_ffmpeg_timestamp_from_frame(segment.end_frame)} "
  98. + f"-acodec copy \"{get_audio_base_path_from_segment(segment)}.wav\""
  99. )
  100. if segment.start_frame >= segment.end_frame:
  101. log("Empty segment detected, generating silent 1 sec wav in place...")
  102. cmd = (
  103. "ffmpeg -y -f lavfi -i anullsrc=r=11025:cl=mono -t 1 "
  104. + f"\"{get_audio_base_path_from_segment(segment)}.wav\""
  105. )
  106. process = Popen(split(cmd))
  107. _ = process.communicate()[0] # wait for subprocess to end
  108. if process.returncode not in [255, 0]:
  109. app = QApplication([])
  110. InfoMsgBox(
  111. QMessageBox.Critical,
  112. "Error",
  113. "ffmpeg terminated with " + f"exit code {process.returncode}",
  114. )
  115. del app
  116. sys.exit(1)
  117. def prepare_audio_files_for_segment_chooser(
  118. segments: list[SermonSegment],
  119. ) -> None:
  120. for segment in segments:
  121. generate_wav_for_segment(segment)
  122. def get_possible_sermon_segments_of_day(yyyy_mm_dd: str) -> list[SermonSegment]:
  123. try:
  124. segments = []
  125. day_dir = path.join(const.CD_RECORD_OUTPUT_BASEDIR, yyyy_mm_dd)
  126. files = sorted(listdir(day_dir))
  127. cue_sheets = []
  128. for file in files:
  129. if is_legal_sheet_filename(file):
  130. cue_sheets.append(file)
  131. for sheet in cue_sheets:
  132. with open(
  133. path.join(day_dir, sheet),
  134. mode="r",
  135. encoding="utf-8",
  136. ) as sheet_reader:
  137. sheet_content = sheet_reader.readlines()
  138. start_frame = 0
  139. end_frame = 0
  140. wav_path = ""
  141. max_line_num = 0
  142. for line_num, line in enumerate(sheet_content):
  143. max_line_num = line_num
  144. if line_num == 0:
  145. if not match(r"^FILE \".+\" WAVE$", line):
  146. raise CustomException("invalid first cue sheet line")
  147. wav_path = line[line.find('"') + 1 :]
  148. wav_path = wav_path[: wav_path.rfind('"')]
  149. elif match(r"^\s+INDEX 01 ([0-9]{2}:){2}[0-9]{2}\s*$", line):
  150. if line_num != 2:
  151. end_frame = get_index_line_as_frames(line)
  152. segments.append(
  153. SermonSegment(
  154. start_frame,
  155. end_frame,
  156. path.join(day_dir, sheet),
  157. (max_line_num - 2) // 2,
  158. )
  159. )
  160. start_frame = end_frame
  161. segments.append(
  162. SermonSegment(
  163. start_frame,
  164. get_wave_duration_in_frames(wav_path),
  165. path.join(day_dir, sheet),
  166. max_line_num // 2,
  167. )
  168. )
  169. return segments
  170. except (
  171. FileNotFoundError,
  172. PermissionError,
  173. IOError,
  174. CustomException,
  175. ) as error:
  176. InfoMsgBox(
  177. QMessageBox.Critical,
  178. "Error",
  179. f"Error: Could not parse sermon segments. Reason: {error}",
  180. )
  181. sys.exit(1)
  182. def get_segments_with_suitable_time(
  183. segments: list[SermonSegment],
  184. ) -> list[SermonSegment]:
  185. suitable_segments = []
  186. for segment in segments:
  187. if (
  188. segment.end_frame - segment.start_frame
  189. >= const.SERMON_UPLOAD_SUITABLE_SEGMENT_FRAMES
  190. ):
  191. suitable_segments.append(segment)
  192. return suitable_segments
  193. def upload_sermon_audiofile(audiofile: str) -> None:
  194. try:
  195. ext = ".mp3"
  196. session = ftplib.FTP_TLS(
  197. const.SERMON_UPLOAD_FTP_HOSTNAME,
  198. const.SERMON_UPLOAD_FTP_USER,
  199. const.SERMON_UPLOAD_FTP_PASSWORD,
  200. )
  201. session.cwd(const.SERMON_UPLOAD_FTP_UPLOAD_DIR)
  202. raw_filenames = session.nlst()
  203. disallowed_filenames = []
  204. for filename in raw_filenames:
  205. if filename not in (".", ".."):
  206. disallowed_filenames.append(filename)
  207. app = QApplication([])
  208. wanted_filename, accepted_dialog = QInputDialog.getText(
  209. None,
  210. "Input Dialog",
  211. f"Enter the filename for the Sermon (the {ext} can be omitted):",
  212. )
  213. del app
  214. if not wanted_filename.endswith(ext):
  215. wanted_filename = wanted_filename + ext
  216. if not accepted_dialog or wanted_filename == ext:
  217. session.quit()
  218. sys.exit(0)
  219. if wanted_filename in disallowed_filenames:
  220. InfoMsgBox(
  221. QMessageBox.Critical, "Error", "Error: filename already exists."
  222. )
  223. session.quit()
  224. sys.exit(1)
  225. mp3_final_path = path.join(path.split(audiofile)[0], wanted_filename)
  226. make_sermon_mp3(audiofile, mp3_final_path)
  227. with open(mp3_final_path, "rb") as file:
  228. session.storbinary(f"STOR {path.split(mp3_final_path)[1]}", file)
  229. session.quit()
  230. InfoMsgBox(
  231. QMessageBox.Information,
  232. "Success",
  233. f"Sermon '{mp3_final_path}' uploaded successfully.",
  234. )
  235. except (
  236. *ftplib.all_errors,
  237. FileNotFoundError,
  238. PermissionError,
  239. IOError,
  240. ) as error:
  241. InfoMsgBox(
  242. QMessageBox.Critical,
  243. "Error",
  244. f"Error: Could not connect to ftp server. Reason: {error}",
  245. )
  246. sys.exit(1)
  247. def upload_sermon_for_day(yyyy_mm_dd: str, choose_manually=False):
  248. segments = get_possible_sermon_segments_of_day(yyyy_mm_dd)
  249. if not segments:
  250. InfoMsgBox(
  251. QMessageBox.Critical,
  252. "Error",
  253. f"Error: No segment for day '{yyyy_mm_dd}' found",
  254. )
  255. sys.exit(1)
  256. suitable_segments = get_segments_with_suitable_time(segments)
  257. if len(suitable_segments) == 1 and not choose_manually:
  258. generate_wav_for_segment(suitable_segments[0])
  259. upload_sermon_audiofile(
  260. f"{get_audio_base_path_from_segment(suitable_segments[0])}.wav"
  261. )
  262. else:
  263. manually_choose_and_upload_segments(segments, yyyy_mm_dd)
  264. def manually_choose_and_upload_segments(segments, yyyy_mm_dd):
  265. prepare_audio_files_for_segment_chooser(segments)
  266. rel_wave_paths = []
  267. for segment in segments:
  268. rel_wave_paths.append(f"{get_audio_rel_path_from_segment(segment)}.wav")
  269. app = QApplication([])
  270. target_dir = path.join(
  271. expand_dir(const.CD_RECORD_OUTPUT_BASEDIR), yyyy_mm_dd
  272. )
  273. dialog = WaveAndSheetPreviewChooserGUI(
  274. target_dir,
  275. rel_wave_paths,
  276. f"Preview CD's for {yyyy_mm_dd}",
  277. AudioSourceFileType.WAVE,
  278. )
  279. if dialog.exec_() == QDialog.Accepted:
  280. if not dialog.chosen_audios:
  281. sys.exit(0)
  282. chosen_wave_paths = []
  283. for chosen_audio in dialog.chosen_audios:
  284. chosen_wave_paths.append(chosen_audio.wave_abs_path)
  285. del app # pyright: ignore
  286. merge_wave_files(target_dir, chosen_wave_paths)
  287. upload_sermon_audiofile(path.join(target_dir, "merged.wav"))
  288. def merge_wave_files(target_dir: str, wave_paths: list[str]) -> None:
  289. concat_file_path = path.join(target_dir, "concat.txt")
  290. log(f"Merging into mp3 file from wave files: {wave_paths}")
  291. create_concat_file(concat_file_path, wave_paths)
  292. merge_files_with_ffmpeg(concat_file_path, target_dir)
  293. def create_concat_file(file_path: str, wave_paths: list[str]) -> None:
  294. try:
  295. with open(file_path, mode="w+", encoding="utf-8") as writer:
  296. for wave_path in wave_paths:
  297. if not "'" in wave_path:
  298. writer.write(f"file '{wave_path}'\n")
  299. else:
  300. writer.write(f'file "{wave_path}"\n')
  301. except (FileNotFoundError, PermissionError, IOError) as error:
  302. app = QApplication
  303. InfoMsgBox(
  304. QMessageBox.Critical,
  305. "Error",
  306. f"Failed to write to '{file_path}'. Reason: {error}",
  307. )
  308. del app
  309. sys.exit(1)
  310. def merge_files_with_ffmpeg(concat_file_path, target_dir) -> None:
  311. cmd = "ffmpeg -y -f concat -safe 0 -i \"{}\" -acodec copy \"{}\"".format(
  312. concat_file_path,
  313. path.join(target_dir, "merged.wav"),
  314. )
  315. process = Popen(split(cmd))
  316. _ = process.communicate()[0] # wait for subprocess to end
  317. if process.returncode not in [255, 0]:
  318. app = QApplication([])
  319. InfoMsgBox(
  320. QMessageBox.Critical,
  321. "Error",
  322. "ffmpeg terminated with " + f"exit code {process.returncode}",
  323. )
  324. del app