sermon.py 19 KB

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