sermon.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  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. process = Popen(split(cmd))
  101. _ = process.communicate()[0] # wait for subprocess to end
  102. if process.returncode not in [255, 0]:
  103. app = QApplication([])
  104. InfoMsgBox(
  105. QMessageBox.Critical,
  106. "Error",
  107. "ffmpeg terminated with " + f"exit code {process.returncode}",
  108. )
  109. del app
  110. sys.exit(1)
  111. def prepare_audio_files_for_segment_chooser(
  112. segments: list[SermonSegment],
  113. ) -> None:
  114. for segment in segments:
  115. generate_wav_for_segment(segment)
  116. def get_possible_sermon_segments_of_day(yyyy_mm_dd: str) -> list[SermonSegment]:
  117. try:
  118. segments = []
  119. day_dir = path.join(const.CD_RECORD_OUTPUT_BASEDIR, yyyy_mm_dd)
  120. files = sorted(listdir(day_dir))
  121. cue_sheets = []
  122. for file in files:
  123. if is_legal_sheet_filename(file):
  124. cue_sheets.append(file)
  125. for sheet in cue_sheets:
  126. with open(
  127. path.join(day_dir, sheet),
  128. mode="r",
  129. encoding="utf-8",
  130. ) as sheet_reader:
  131. sheet_content = sheet_reader.readlines()
  132. start_frame = 0
  133. end_frame = 0
  134. wav_path = ""
  135. max_line_num = 0
  136. for line_num, line in enumerate(sheet_content):
  137. max_line_num = line_num
  138. if line_num == 0:
  139. if not match(r"^FILE \".+\" WAVE$", line):
  140. raise CustomException("invalid first cue sheet line")
  141. wav_path = line[line.find('"') + 1 :]
  142. wav_path = wav_path[: wav_path.rfind('"')]
  143. elif match(r"^\s+INDEX 01 ([0-9]{2}:){2}[0-9]{2}\s*$", line):
  144. if line_num != 2:
  145. end_frame = get_index_line_as_frames(line)
  146. segments.append(
  147. SermonSegment(
  148. start_frame,
  149. end_frame,
  150. path.join(day_dir, sheet),
  151. (max_line_num - 2) // 2,
  152. )
  153. )
  154. start_frame = end_frame
  155. segments.append(
  156. SermonSegment(
  157. start_frame,
  158. get_wave_duration_in_frames(wav_path),
  159. path.join(day_dir, sheet),
  160. max_line_num // 2,
  161. )
  162. )
  163. return segments
  164. except (
  165. FileNotFoundError,
  166. PermissionError,
  167. IOError,
  168. CustomException,
  169. ) as error:
  170. InfoMsgBox(
  171. QMessageBox.Critical,
  172. "Error",
  173. f"Error: Could not parse sermon segments. Reason: {error}",
  174. )
  175. sys.exit(1)
  176. def get_segments_with_suitable_time(
  177. segments: list[SermonSegment],
  178. ) -> list[SermonSegment]:
  179. suitable_segments = []
  180. for segment in segments:
  181. if (
  182. segment.end_frame - segment.start_frame
  183. >= const.SERMON_UPLOAD_SUITABLE_SEGMENT_FRAMES
  184. ):
  185. suitable_segments.append(segment)
  186. return suitable_segments
  187. def upload_sermon_audiofile(audiofile: str) -> None:
  188. try:
  189. ext = ".mp3"
  190. session = ftplib.FTP_TLS(
  191. const.SERMON_UPLOAD_FTP_HOSTNAME,
  192. const.SERMON_UPLOAD_FTP_USER,
  193. const.SERMON_UPLOAD_FTP_PASSWORD,
  194. )
  195. session.cwd(const.SERMON_UPLOAD_FTP_UPLOAD_DIR)
  196. raw_filenames = session.nlst()
  197. disallowed_filenames = []
  198. for filename in raw_filenames:
  199. if filename not in (".", ".."):
  200. disallowed_filenames.append(filename)
  201. app = QApplication([])
  202. wanted_filename, accepted_dialog = QInputDialog.getText(
  203. None,
  204. "Input Dialog",
  205. f"Enter the filename for the Sermon (the {ext} can be omitted):",
  206. )
  207. del app
  208. if not wanted_filename.endswith(ext):
  209. wanted_filename = wanted_filename + ext
  210. if not accepted_dialog or wanted_filename == ext:
  211. session.quit()
  212. sys.exit(0)
  213. if wanted_filename in disallowed_filenames:
  214. InfoMsgBox(
  215. QMessageBox.Critical, "Error", "Error: filename already exists."
  216. )
  217. session.quit()
  218. sys.exit(1)
  219. mp3_final_path = path.join(path.split(audiofile)[0], wanted_filename)
  220. print(mp3_final_path)
  221. make_sermon_mp3(audiofile, mp3_final_path)
  222. with open(mp3_final_path, "rb") as file:
  223. session.storbinary(f"STOR {path.split(mp3_final_path)[1]}", file)
  224. session.quit()
  225. InfoMsgBox(
  226. QMessageBox.Information,
  227. "Success",
  228. f"Sermon '{mp3_final_path}' uploaded successfully.",
  229. )
  230. except (
  231. *ftplib.all_errors,
  232. FileNotFoundError,
  233. PermissionError,
  234. IOError,
  235. ) as error:
  236. InfoMsgBox(
  237. QMessageBox.Critical,
  238. "Error",
  239. f"Error: Could not connect to ftp server. Reason: {error}",
  240. )
  241. sys.exit(1)
  242. def upload_sermon_for_day(yyyy_mm_dd: str, choose_manually=False):
  243. segments = get_possible_sermon_segments_of_day(yyyy_mm_dd)
  244. if not segments:
  245. InfoMsgBox(
  246. QMessageBox.Critical,
  247. "Error",
  248. f"Error: No segment for day '{yyyy_mm_dd}' found",
  249. )
  250. sys.exit(1)
  251. suitable_segments = get_segments_with_suitable_time(segments)
  252. if len(suitable_segments) == 1 and not choose_manually:
  253. generate_wav_for_segment(suitable_segments[0])
  254. upload_sermon_audiofile(
  255. f"{get_audio_base_path_from_segment(suitable_segments[0])}.wav"
  256. )
  257. else:
  258. manually_choose_and_upload_segments(segments, yyyy_mm_dd)
  259. def manually_choose_and_upload_segments(segments, yyyy_mm_dd):
  260. prepare_audio_files_for_segment_chooser(segments)
  261. rel_wave_paths = []
  262. for segment in segments:
  263. rel_wave_paths.append(f"{get_audio_rel_path_from_segment(segment)}.wav")
  264. app = QApplication([])
  265. target_dir = path.join(
  266. expand_dir(const.CD_RECORD_OUTPUT_BASEDIR), yyyy_mm_dd
  267. )
  268. dialog = WaveAndSheetPreviewChooserGUI(
  269. target_dir,
  270. rel_wave_paths,
  271. f"Preview CD's for {yyyy_mm_dd}",
  272. AudioSourceFileType.WAVE,
  273. )
  274. if dialog.exec_() == QDialog.Accepted:
  275. if not dialog.chosen_audios:
  276. sys.exit(0)
  277. chosen_wave_paths = []
  278. for chosen_audio in dialog.chosen_audios:
  279. chosen_wave_paths.append(chosen_audio.wave_abs_path)
  280. del app # pyright: ignore
  281. merge_wave_files(target_dir, chosen_wave_paths)
  282. upload_sermon_audiofile(path.join(target_dir, "merged.wav"))
  283. def merge_wave_files(target_dir: str, wave_paths: list[str]) -> None:
  284. concat_file_path = path.join(target_dir, "concat.txt")
  285. log(f"Merging into mp3 file from wave files: {wave_paths}")
  286. create_concat_file(concat_file_path, wave_paths)
  287. merge_files_with_ffmpeg(concat_file_path, target_dir)
  288. def create_concat_file(file_path: str, wave_paths: list[str]) -> None:
  289. try:
  290. with open(file_path, mode="w+", encoding="utf-8") as writer:
  291. for wave_path in wave_paths:
  292. if not "'" in wave_path:
  293. writer.write(f"file '{wave_path}'\n")
  294. else:
  295. writer.write(f'file "{wave_path}"\n')
  296. except (FileNotFoundError, PermissionError, IOError) as error:
  297. app = QApplication
  298. InfoMsgBox(
  299. QMessageBox.Critical,
  300. "Error",
  301. f"Failed to write to '{file_path}'. Reason: {error}",
  302. )
  303. del app
  304. sys.exit(1)
  305. def merge_files_with_ffmpeg(concat_file_path, target_dir) -> None:
  306. cmd = "ffmpeg -y -f concat -safe 0 -i {} -acodec copy {}".format(
  307. concat_file_path,
  308. path.join(target_dir, "merged.wav"),
  309. )
  310. process = Popen(split(cmd))
  311. _ = process.communicate()[0] # wait for subprocess to end
  312. if process.returncode not in [255, 0]:
  313. app = QApplication([])
  314. InfoMsgBox(
  315. QMessageBox.Critical,
  316. "Error",
  317. "ffmpeg terminated with " + f"exit code {process.returncode}",
  318. )
  319. del app