sermon.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  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
  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. AudioSourceFileType,
  32. )
  33. from input import WaveAndSheetPreviewChooserGUI
  34. import config as const
  35. from .verify import (
  36. get_padded_cd_num_from_sheet_filename,
  37. is_legal_sheet_filename,
  38. )
  39. def get_full_wav_path(segment: SermonSegment) -> str:
  40. try:
  41. with open(
  42. segment.source_cue_sheet,
  43. mode="r",
  44. encoding="utf-8-sig",
  45. ) as cue_sheet_reader:
  46. cue_sheet_content = cue_sheet_reader.readlines()
  47. first_line = cue_sheet_content[0].strip()
  48. if not match(r"^FILE \".+\" WAVE$", first_line):
  49. raise CustomException("invalid first cue sheet line")
  50. full_wav_path = first_line[first_line.find('"') + 1 :]
  51. return full_wav_path[: full_wav_path.rfind('"')]
  52. except (
  53. FileNotFoundError,
  54. PermissionError,
  55. IOError,
  56. CustomException,
  57. ) as error:
  58. app = QApplication([])
  59. QMessageBox.critical(
  60. None,
  61. "Error",
  62. f"Could not parse cue sheet: '{segment.source_cue_sheet}',"
  63. + f"Reason: {error}",
  64. )
  65. del app
  66. sys.exit(1)
  67. def get_audio_rel_path_from_segment(segment: SermonSegment) -> str:
  68. splitted_sheet_path = path.split(segment.source_cue_sheet)
  69. yyyy_mm_dd = path.split(splitted_sheet_path[0])[1]
  70. cd_num = get_padded_cd_num_from_sheet_filename(splitted_sheet_path[1])
  71. return f"{yyyy_mm_dd}-{cd_num}-segment-{segment.source_marker}"
  72. def get_audio_base_path_from_segment(segment: SermonSegment) -> str:
  73. base_path = path.split(segment.source_cue_sheet)[0]
  74. return path.join(
  75. base_path,
  76. get_audio_rel_path_from_segment(segment),
  77. )
  78. def make_sermon_mp3(source_audio: str, target_audio: str) -> None:
  79. log("Generating final mp3...")
  80. cmd = "ffmpeg -y -i {} -acodec libmp3lame {}".format(
  81. source_audio,
  82. target_audio,
  83. )
  84. process = Popen(split(cmd))
  85. _ = process.communicate()[0] # wait for subprocess to end
  86. if process.returncode not in [255, 0]:
  87. app = QApplication([])
  88. InfoMsgBox(
  89. QMessageBox.Critical,
  90. "Error",
  91. "ffmpeg terminated with " + f"exit code {process.returncode}",
  92. )
  93. del app
  94. def generate_wav_for_segment(segment: SermonSegment) -> None:
  95. cmd = (
  96. f"ffmpeg -y -i {get_full_wav_path(segment)} -ss "
  97. + f" {get_ffmpeg_timestamp_from_frame(segment.start_frame)} "
  98. + f"-to {get_ffmpeg_timestamp_from_frame(segment.end_frame)} "
  99. + f"-acodec copy {get_audio_base_path_from_segment(segment)}.wav"
  100. )
  101. process = Popen(split(cmd))
  102. _ = process.communicate()[0] # wait for subprocess to end
  103. if process.returncode not in [255, 0]:
  104. app = QApplication([])
  105. InfoMsgBox(
  106. QMessageBox.Critical,
  107. "Error",
  108. "ffmpeg terminated with " + f"exit code {process.returncode}",
  109. )
  110. del app
  111. sys.exit(1)
  112. def prepare_audio_files_for_segment_chooser(
  113. segments: list[SermonSegment],
  114. ) -> None:
  115. for segment in segments:
  116. generate_wav_for_segment(segment)
  117. def get_possible_sermon_segments_of_day(yyyy_mm_dd: str) -> list[SermonSegment]:
  118. try:
  119. segments = []
  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 in 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. suitable_segments.append(segment)
  187. return suitable_segments
  188. def upload_sermon_audiofile(audiofile: str) -> None:
  189. try:
  190. ext = ".mp3"
  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. f"Enter the filename for the Sermon (the {ext} can be omitted):",
  207. )
  208. del app
  209. if not wanted_filename.endswith(ext):
  210. wanted_filename = wanted_filename + ext
  211. if not accepted_dialog or wanted_filename == ext:
  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. mp3_final_path = path.join(path.split(audiofile)[0], wanted_filename)
  221. print(mp3_final_path)
  222. make_sermon_mp3(audiofile, 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,
  228. "Success",
  229. f"Sermon '{mp3_final_path}' uploaded successfully.",
  230. )
  231. except (
  232. *ftplib.all_errors,
  233. FileNotFoundError,
  234. PermissionError,
  235. IOError,
  236. ) as error:
  237. InfoMsgBox(
  238. QMessageBox.Critical,
  239. "Error",
  240. f"Error: Could not connect to ftp server. Reason: {error}",
  241. )
  242. sys.exit(1)
  243. def upload_sermon_for_day(yyyy_mm_dd: str, choose_manually=False):
  244. segments = get_possible_sermon_segments_of_day(yyyy_mm_dd)
  245. if not segments:
  246. InfoMsgBox(
  247. QMessageBox.Critical,
  248. "Error",
  249. f"Error: No segment for day '{yyyy_mm_dd}' found",
  250. )
  251. sys.exit(1)
  252. suitable_segments = get_segments_with_suitable_time(segments)
  253. if len(suitable_segments) == 1 and not choose_manually:
  254. generate_wav_for_segment(suitable_segments[0])
  255. upload_sermon_audiofile(
  256. f"{get_audio_base_path_from_segment(suitable_segments[0])}.wav"
  257. )
  258. else:
  259. manually_choose_and_upload_segments(segments, yyyy_mm_dd)
  260. def manually_choose_and_upload_segments(segments, yyyy_mm_dd):
  261. prepare_audio_files_for_segment_chooser(segments)
  262. rel_wave_paths = []
  263. for segment in segments:
  264. rel_wave_paths.append(f"{get_audio_rel_path_from_segment(segment)}.wav")
  265. app = QApplication([])
  266. target_dir = path.join(
  267. expand_dir(const.CD_RECORD_OUTPUT_BASEDIR), yyyy_mm_dd
  268. )
  269. dialog = WaveAndSheetPreviewChooserGUI(
  270. target_dir,
  271. rel_wave_paths,
  272. f"Preview CD's for {yyyy_mm_dd}",
  273. AudioSourceFileType.WAVE,
  274. )
  275. if dialog.exec_() == QDialog.Accepted:
  276. if not dialog.chosen_audios:
  277. sys.exit(0)
  278. chosen_wave_paths = []
  279. for chosen_audio in dialog.chosen_audios:
  280. chosen_wave_paths.append(chosen_audio.wave_abs_path)
  281. del app # pyright: ignore
  282. merge_wave_files(target_dir, chosen_wave_paths)
  283. upload_sermon_audiofile(path.join(target_dir, "merged.wav"))
  284. def merge_wave_files(target_dir: str, wave_paths: list[str]) -> None:
  285. concat_file_path = path.join(target_dir, "concat.txt")
  286. log(f"Merging into mp3 file from wave files: {wave_paths}")
  287. create_concat_file(concat_file_path, wave_paths)
  288. merge_files_with_ffmpeg(concat_file_path, target_dir)
  289. def create_concat_file(file_path: str, wave_paths: list[str]) -> None:
  290. try:
  291. with open(file_path, mode="w+", encoding="utf-8") as writer:
  292. for wave_path in wave_paths:
  293. if not "'" in wave_path:
  294. writer.write(f"file '{wave_path}'\n")
  295. else:
  296. writer.write(f'file "{wave_path}"\n')
  297. except (FileNotFoundError, PermissionError, IOError) as error:
  298. app = QApplication
  299. InfoMsgBox(
  300. QMessageBox.Critical,
  301. "Error",
  302. f"Failed to write to '{file_path}'. Reason: {error}",
  303. )
  304. del app
  305. sys.exit(1)
  306. def merge_files_with_ffmpeg(concat_file_path, target_dir) -> None:
  307. cmd = "ffmpeg -y -f concat -safe 0 -i {} -acodec copy {}".format(
  308. concat_file_path,
  309. path.join(target_dir, "merged.wav"),
  310. )
  311. process = Popen(split(cmd))
  312. _ = process.communicate()[0] # wait for subprocess to end
  313. if process.returncode not in [255, 0]:
  314. app = QApplication([])
  315. InfoMsgBox(
  316. QMessageBox.Critical,
  317. "Error",
  318. "ffmpeg terminated with " + f"exit code {process.returncode}",
  319. )
  320. del app