sermon.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  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, sub
  16. from subprocess import Popen
  17. import ftplib
  18. from datetime import datetime, timezone
  19. from dataclasses import dataclass
  20. import requests
  21. from requests.auth import HTTPBasicAuth
  22. from PyQt5.QtWidgets import ( # pylint: disable=no-name-in-module
  23. QApplication,
  24. QMessageBox,
  25. QInputDialog,
  26. QDialog,
  27. )
  28. from utils import (
  29. CustomException,
  30. expand_dir,
  31. log,
  32. InfoMsgBox,
  33. RadioButtonDialog,
  34. )
  35. from audio import (
  36. get_ffmpeg_timestamp_from_frame,
  37. SermonSegment,
  38. get_wave_duration_in_frames,
  39. get_index_line_as_frames,
  40. AudioSourceFileType,
  41. )
  42. import config as const
  43. from .verify import (
  44. get_padded_cd_num_from_sheet_filename,
  45. is_legal_sheet_filename,
  46. )
  47. from .gui import WaveAndSheetPreviewChooserGUI
  48. def get_full_wav_path(segment: SermonSegment) -> str:
  49. try:
  50. with open(
  51. segment.source_cue_sheet,
  52. mode="r",
  53. encoding="utf-8",
  54. ) as cue_sheet_reader:
  55. cue_sheet_content = cue_sheet_reader.readlines()
  56. first_line = cue_sheet_content[0].strip()
  57. if not match(r"^FILE \".+\" WAVE$", first_line):
  58. raise CustomException("invalid first cue sheet line")
  59. full_wav_path = first_line[first_line.find('"') + 1 :]
  60. return full_wav_path[: full_wav_path.rfind('"')]
  61. except (
  62. FileNotFoundError,
  63. PermissionError,
  64. IOError,
  65. CustomException,
  66. ) as error:
  67. app = QApplication([])
  68. QMessageBox.critical(
  69. None,
  70. "Error",
  71. f"Could not parse cue sheet: '{segment.source_cue_sheet}',"
  72. + f"Reason: {error}",
  73. )
  74. del app
  75. sys.exit(1)
  76. def get_audio_rel_path_from_segment(segment: SermonSegment) -> str:
  77. splitted_sheet_path = path.split(segment.source_cue_sheet)
  78. yyyy_mm_dd = path.split(splitted_sheet_path[0])[1]
  79. cd_num = get_padded_cd_num_from_sheet_filename(splitted_sheet_path[1])
  80. return f"{yyyy_mm_dd}-{cd_num}-segment-{segment.source_marker}"
  81. def get_audio_base_path_from_segment(segment: SermonSegment) -> str:
  82. base_path = path.split(segment.source_cue_sheet)[0]
  83. return path.join(
  84. base_path,
  85. get_audio_rel_path_from_segment(segment),
  86. )
  87. def make_sermon_mp3(source_audio: str, target_audio: str) -> None:
  88. log("Generating final mp3...")
  89. cmd = 'ffmpeg -y -i "{}" -acodec libmp3lame "{}"'.format(
  90. source_audio,
  91. target_audio,
  92. )
  93. process = Popen(split(cmd))
  94. _ = process.communicate()[0] # wait for subprocess to end
  95. if process.returncode not in [255, 0]:
  96. app = QApplication([])
  97. InfoMsgBox(
  98. QMessageBox.Critical,
  99. "Error",
  100. "ffmpeg terminated with " + f"exit code {process.returncode}",
  101. )
  102. del app
  103. def generate_wav_for_segment(segment: SermonSegment) -> None:
  104. cmd = (
  105. f'ffmpeg -y -i "{get_full_wav_path(segment)}" -ss '
  106. + f" {get_ffmpeg_timestamp_from_frame(segment.start_frame)} "
  107. + f"-to {get_ffmpeg_timestamp_from_frame(segment.end_frame)} "
  108. + f'-acodec copy "{get_audio_base_path_from_segment(segment)}.wav"'
  109. )
  110. if segment.start_frame >= segment.end_frame:
  111. log("Empty segment detected, generating silent 1 sec wav in place...")
  112. cmd = (
  113. "ffmpeg -y -f lavfi -i anullsrc=r=11025:cl=mono -t 1 "
  114. + f'"{get_audio_base_path_from_segment(segment)}.wav"'
  115. )
  116. process = Popen(split(cmd))
  117. _ = process.communicate()[0] # wait for subprocess to end
  118. if process.returncode not in [255, 0]:
  119. app = QApplication([])
  120. InfoMsgBox(
  121. QMessageBox.Critical,
  122. "Error",
  123. "ffmpeg terminated with " + f"exit code {process.returncode}",
  124. )
  125. del app
  126. sys.exit(1)
  127. def prepare_audio_files_for_segment_chooser(
  128. segments: list[SermonSegment],
  129. ) -> None:
  130. for segment in segments:
  131. generate_wav_for_segment(segment)
  132. def get_possible_sermon_segments_of_day(yyyy_mm_dd: str) -> list[SermonSegment]:
  133. try:
  134. segments = []
  135. day_dir = path.join(const.CD_RECORD_OUTPUT_BASEDIR, yyyy_mm_dd)
  136. files = sorted(listdir(day_dir))
  137. cue_sheets = []
  138. for file in files:
  139. if is_legal_sheet_filename(file):
  140. cue_sheets.append(file)
  141. for sheet in cue_sheets:
  142. with open(
  143. path.join(day_dir, sheet),
  144. mode="r",
  145. encoding="utf-8",
  146. ) as sheet_reader:
  147. sheet_content = sheet_reader.readlines()
  148. start_frame = 0
  149. end_frame = 0
  150. wav_path = ""
  151. max_line_num = 0
  152. for line_num, line in enumerate(sheet_content):
  153. max_line_num = line_num
  154. if line_num == 0:
  155. if not match(r"^FILE \".+\" WAVE$", line):
  156. raise CustomException("invalid first cue sheet line")
  157. wav_path = line[line.find('"') + 1 :]
  158. wav_path = wav_path[: wav_path.rfind('"')]
  159. elif match(r"^\s+INDEX 01 ([0-9]{2}:){2}[0-9]{2}\s*$", line):
  160. if line_num != 2:
  161. end_frame = get_index_line_as_frames(line)
  162. segments.append(
  163. SermonSegment(
  164. start_frame,
  165. end_frame,
  166. path.join(day_dir, sheet),
  167. (max_line_num - 2) // 2,
  168. )
  169. )
  170. start_frame = end_frame
  171. segments.append(
  172. SermonSegment(
  173. start_frame,
  174. get_wave_duration_in_frames(wav_path),
  175. path.join(day_dir, sheet),
  176. max_line_num // 2,
  177. )
  178. )
  179. return segments
  180. except (
  181. FileNotFoundError,
  182. PermissionError,
  183. IOError,
  184. CustomException,
  185. ) as error:
  186. InfoMsgBox(
  187. QMessageBox.Critical,
  188. "Error",
  189. f"Error: Could not parse sermon segments. Reason: {error}",
  190. )
  191. sys.exit(1)
  192. def get_segments_with_suitable_time(
  193. segments: list[SermonSegment],
  194. ) -> list[SermonSegment]:
  195. suitable_segments = []
  196. for segment in segments:
  197. if (
  198. segment.end_frame - segment.start_frame
  199. >= const.SERMON_UPLOAD_SUITABLE_SEGMENT_FRAMES
  200. ):
  201. suitable_segments.append(segment)
  202. return suitable_segments
  203. @dataclass
  204. class Preacher:
  205. id: int
  206. name: str
  207. def get_preachers():
  208. try:
  209. response = requests.get(
  210. const.SERMON_UPLOAD_WPSM_API_BASE_URL + "/wpfc_preacher",
  211. timeout=const.HTTP_REQUEST_TIMEOUT_SECONDS,
  212. )
  213. response.raise_for_status()
  214. data = response.json()
  215. preachers: list[Preacher] = []
  216. for preacher in data:
  217. preacher_id = int(preacher["id"])
  218. # pylint: disable=invalid-name
  219. name = str(preacher["name"])
  220. preachers.append(Preacher(preacher_id, name))
  221. if len(preachers) == 0:
  222. raise CustomException("Not enough preachers")
  223. return preachers
  224. except CustomException as e:
  225. InfoMsgBox(
  226. QMessageBox.Critical,
  227. "Error",
  228. f"Error: Could not get preachers. Reason: {e}",
  229. )
  230. sys.exit(1)
  231. except (
  232. requests.exceptions.RequestException,
  233. KeyError,
  234. ValueError,
  235. ) as e:
  236. InfoMsgBox(
  237. QMessageBox.Critical,
  238. "Error",
  239. f"Error: Could not get preachers. Reason: {type(e).__name__}",
  240. )
  241. sys.exit(1)
  242. def choose_preacher() -> Preacher:
  243. preachers = get_preachers()
  244. preacher_names = [preacher.name for preacher in preachers]
  245. preacher_ids = [preacher.id for preacher in preachers]
  246. app = QApplication([])
  247. dialog = RadioButtonDialog(preacher_names, "Choose a Preacher")
  248. if dialog.exec_() == QDialog.Accepted:
  249. log(f"Dialog accepted: {dialog.chosen}")
  250. return Preacher(
  251. preacher_ids[preacher_names.index(dialog.chosen)], dialog.chosen
  252. )
  253. del app
  254. InfoMsgBox(
  255. QMessageBox.Critical,
  256. "Error",
  257. "Error: Sermon upload canceled as no preacher selected.",
  258. )
  259. sys.exit(1)
  260. def make_sermon_filename(sermon_name: str) -> str:
  261. # Custom replacements for umlauts and ß
  262. replacements = {
  263. "ä": "ae",
  264. "ö": "oe",
  265. "ü": "ue",
  266. "Ä": "Ae",
  267. "Ö": "Oe",
  268. "Ü": "Ue",
  269. "ß": "ss",
  270. }
  271. for src, target in replacements.items():
  272. sermon_name = sermon_name.replace(src, target)
  273. sermon_name = sermon_name.replace(" ", "_")
  274. sermon_name = sub(r"[^a-zA-Z0-9_-]", "", sermon_name)
  275. return sermon_name + ".mp3"
  276. def upload_mp3_to_wordpress(filename: str) -> None:
  277. app = QApplication([])
  278. sermon_name, accepted_dialog = QInputDialog.getText(
  279. None,
  280. "Input Dialog",
  281. "Enter the Name of the Sermon:",
  282. )
  283. del app
  284. if not accepted_dialog:
  285. sys.exit(0)
  286. wanted_filename = make_sermon_filename(sermon_name)
  287. mp3_final_path = path.join(path.split(filename)[0], wanted_filename)
  288. make_sermon_mp3(filename, mp3_final_path)
  289. headers = {
  290. "Content-Disposition": f"attachment; filename={wanted_filename}",
  291. "Content-Type": "audio/mpeg",
  292. }
  293. with open(mp3_final_path, "rb") as f:
  294. try:
  295. log(f"uploading f{mp3_final_path} to wordpress...")
  296. response = requests.post(
  297. const.SERMON_UPLOAD_WPSM_API_BASE_URL + "/media",
  298. headers=headers,
  299. data=f,
  300. auth=HTTPBasicAuth(
  301. const.SERMON_UPLOAD_WPSM_USER,
  302. const.SERMON_UPLOAD_WPSM_PASSWORD,
  303. ),
  304. timeout=const.HTTP_REQUEST_TIMEOUT_SECONDS,
  305. )
  306. response.raise_for_status()
  307. data = response.json()
  308. log(f"url of uploaded wordpress media: {data['guid']['rendered']}")
  309. except (
  310. requests.exceptions.RequestException,
  311. # KeyError,
  312. # FileNotFoundError,
  313. # PermissionError,
  314. # IOError,
  315. ) as e:
  316. InfoMsgBox(
  317. QMessageBox.Critical,
  318. "Error",
  319. f"Error: Could not upload sermon to wordpress. Reason: {e}",
  320. )
  321. sys.exit(1)
  322. def upload_sermon_to_ftp_server(audiofile: str) -> None:
  323. try:
  324. ext = ".mp3"
  325. session = ftplib.FTP_TLS(
  326. const.SERMON_UPLOAD_FTP_HOSTNAME,
  327. const.SERMON_UPLOAD_FTP_USER,
  328. const.SERMON_UPLOAD_FTP_PASSWORD,
  329. )
  330. session.cwd(const.SERMON_UPLOAD_FTP_UPLOAD_DIR)
  331. raw_filenames = session.nlst()
  332. disallowed_filenames = []
  333. for filename in raw_filenames:
  334. if filename not in (".", ".."):
  335. disallowed_filenames.append(filename)
  336. app = QApplication([])
  337. wanted_filename, accepted_dialog = QInputDialog.getText(
  338. None,
  339. "Input Dialog",
  340. f"Enter the filename for the Sermon (the {ext} can be omitted):",
  341. )
  342. del app
  343. if not wanted_filename.endswith(ext):
  344. wanted_filename = wanted_filename + ext
  345. if not accepted_dialog or wanted_filename == ext:
  346. session.quit()
  347. sys.exit(0)
  348. if wanted_filename in disallowed_filenames:
  349. InfoMsgBox(
  350. QMessageBox.Critical,
  351. "Error",
  352. "Error: filename already exists.",
  353. )
  354. session.quit()
  355. sys.exit(1)
  356. mp3_final_path = path.join(path.split(audiofile)[0], wanted_filename)
  357. make_sermon_mp3(audiofile, mp3_final_path)
  358. with open(mp3_final_path, "rb") as file:
  359. session.storbinary(f"STOR {path.split(mp3_final_path)[1]}", file)
  360. session.quit()
  361. InfoMsgBox(
  362. QMessageBox.Information,
  363. "Success",
  364. f"Sermon '{mp3_final_path}' uploaded successfully.",
  365. )
  366. except (
  367. *ftplib.all_errors,
  368. FileNotFoundError,
  369. PermissionError,
  370. IOError,
  371. ) as error:
  372. InfoMsgBox(
  373. QMessageBox.Critical,
  374. "Error",
  375. f"Error: Could not connect to ftp server. Reason: {error}",
  376. )
  377. sys.exit(1)
  378. def upload_sermon_audiofile(audiofile: str, yyyy_mm_dd: str) -> None:
  379. if const.SERMON_UPLOAD_USE_FTP:
  380. upload_sermon_to_ftp_server(audiofile)
  381. else:
  382. dt = datetime.fromisoformat(yyyy_mm_dd).replace(tzinfo=timezone.utc)
  383. # puts date to midnight of UTC
  384. unix_seconds = int(dt.timestamp())
  385. preacher = choose_preacher()
  386. upload_mp3_to_wordpress(audiofile)
  387. def upload_sermon_for_day(yyyy_mm_dd: str, choose_manually=False):
  388. segments = get_possible_sermon_segments_of_day(yyyy_mm_dd)
  389. if not segments:
  390. InfoMsgBox(
  391. QMessageBox.Critical,
  392. "Error",
  393. f"Error: No segment for day '{yyyy_mm_dd}' found",
  394. )
  395. sys.exit(1)
  396. suitable_segments = get_segments_with_suitable_time(segments)
  397. if len(suitable_segments) == 1 and not choose_manually:
  398. generate_wav_for_segment(suitable_segments[0])
  399. upload_sermon_audiofile(
  400. f"{get_audio_base_path_from_segment(suitable_segments[0])}.wav",
  401. yyyy_mm_dd,
  402. )
  403. else:
  404. manually_choose_and_upload_segments(segments, yyyy_mm_dd)
  405. def manually_choose_and_upload_segments(segments, yyyy_mm_dd):
  406. prepare_audio_files_for_segment_chooser(segments)
  407. rel_wave_paths = []
  408. for segment in segments:
  409. rel_wave_paths.append(f"{get_audio_rel_path_from_segment(segment)}.wav")
  410. app = QApplication([])
  411. target_dir = path.join(
  412. expand_dir(const.CD_RECORD_OUTPUT_BASEDIR), yyyy_mm_dd
  413. )
  414. dialog = WaveAndSheetPreviewChooserGUI(
  415. target_dir,
  416. rel_wave_paths,
  417. f"Preview CD's for {yyyy_mm_dd}",
  418. AudioSourceFileType.WAVE,
  419. )
  420. if dialog.exec_() == QDialog.Accepted:
  421. if not dialog.chosen_audios:
  422. sys.exit(0)
  423. chosen_wave_paths = []
  424. for chosen_audio in dialog.chosen_audios:
  425. chosen_wave_paths.append(chosen_audio.wave_abs_path)
  426. del app # pyright: ignore
  427. merge_wave_files(target_dir, chosen_wave_paths)
  428. upload_sermon_audiofile(path.join(target_dir, "merged.wav"), yyyy_mm_dd)
  429. def merge_wave_files(target_dir: str, wave_paths: list[str]) -> None:
  430. concat_file_path = path.join(target_dir, "concat.txt")
  431. log(f"Merging into mp3 file from wave files: {wave_paths}")
  432. create_concat_file(concat_file_path, wave_paths)
  433. merge_files_with_ffmpeg(concat_file_path, target_dir)
  434. def create_concat_file(file_path: str, wave_paths: list[str]) -> None:
  435. try:
  436. with open(file_path, mode="w+", encoding="utf-8") as writer:
  437. for wave_path in wave_paths:
  438. if not "'" in wave_path:
  439. writer.write(f"file '{wave_path}'\n")
  440. else:
  441. writer.write(f'file "{wave_path}"\n')
  442. except (FileNotFoundError, PermissionError, IOError) as error:
  443. app = QApplication
  444. InfoMsgBox(
  445. QMessageBox.Critical,
  446. "Error",
  447. f"Failed to write to '{file_path}'. Reason: {error}",
  448. )
  449. del app
  450. sys.exit(1)
  451. def merge_files_with_ffmpeg(concat_file_path, target_dir) -> None:
  452. cmd = 'ffmpeg -y -f concat -safe 0 -i "{}" -acodec copy "{}"'.format(
  453. concat_file_path,
  454. path.join(target_dir, "merged.wav"),
  455. )
  456. process = Popen(split(cmd))
  457. _ = process.communicate()[0] # wait for subprocess to end
  458. if process.returncode not in [255, 0]:
  459. app = QApplication([])
  460. InfoMsgBox(
  461. QMessageBox.Critical,
  462. "Error",
  463. "ffmpeg terminated with " + f"exit code {process.returncode}",
  464. )
  465. del app