scripts.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. """
  2. Copyright © 2022 Noah Vogt <noah@noahvogt.com>
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU General Public License as published by
  5. the Free Software Foundation, either version 3 of the License, or
  6. (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU General Public License for more details.
  11. You should have received a copy of the GNU General Public License
  12. along with this program. If not, see <http://www.gnu.org/licenses/>.
  13. """
  14. import sys
  15. from os import path, listdir
  16. from subprocess import Popen
  17. from shlex import split
  18. from time import sleep
  19. from re import match
  20. from enum import Enum
  21. from dataclasses import dataclass
  22. import ftplib
  23. from pyautogui import keyDown, keyUp
  24. from PyQt5.QtWidgets import ( # pylint: disable=no-name-in-module
  25. QApplication,
  26. QMessageBox,
  27. QInputDialog,
  28. )
  29. from PyQt5.QtWidgets import ( # pylint: disable=no-name-in-module
  30. QDialog,
  31. )
  32. from PyQt5.QtCore import QTimer # pylint: disable=no-name-in-module
  33. from utils import (
  34. log,
  35. error_msg,
  36. get_yyyy_mm_dd_date,
  37. expand_dir,
  38. CustomException,
  39. get_wave_duration_in_frames,
  40. )
  41. from input import (
  42. validate_cd_record_config,
  43. RadioButtonDialog,
  44. InfoMsgBox,
  45. SheetAndPreviewChooser,
  46. get_cachefile_content,
  47. )
  48. from os_agnostic import get_cd_drives, eject_drive
  49. import config as const
  50. def make_sure_file_exists(cachefile: str) -> None:
  51. if not path.isfile(cachefile):
  52. try:
  53. with open(
  54. cachefile, mode="w+", encoding="utf-8-sig"
  55. ) as file_creator:
  56. file_creator.write("")
  57. except (FileNotFoundError, PermissionError, IOError) as error:
  58. error_msg(
  59. "Failed to create file in '{}'. Reason: {}".format(
  60. cachefile, error
  61. )
  62. )
  63. def choose_right_cd_drive(drives: list) -> str:
  64. if len(drives) != 1:
  65. log("Warning: More than one cd drive found", color="yellow")
  66. if (
  67. const.CD_RECORD_PREFERED_DRIVE in drives
  68. and const.CD_RECORD_PREFERED_DRIVE != ""
  69. ):
  70. return const.CD_RECORD_PREFERED_DRIVE
  71. dialog = RadioButtonDialog(drives, "Choose a CD to Burn")
  72. if dialog.exec_() == QDialog.Accepted:
  73. print(f"Dialog accepted: {dialog.chosen_sheets}")
  74. return dialog.chosen_sheets
  75. log("Warning: Choosing first cd drive...", color="yellow")
  76. return drives[0]
  77. def get_burn_cmd(cd_drive: str, yyyy_mm_dd, padded_zfill_num: str) -> str:
  78. cue_sheet_path = path.join(
  79. expand_dir(const.CD_RECORD_OUTPUT_BASEDIR),
  80. yyyy_mm_dd,
  81. f"sheet-{padded_zfill_num}.cue",
  82. )
  83. return (
  84. f"cdrecord -pad dev={cd_drive} -dao -swab -text -audio "
  85. + f"-cuefile='{cue_sheet_path}'"
  86. )
  87. class SongDirection(Enum):
  88. PREVIOUS = "previous"
  89. NEXT = "next"
  90. def cycle_to_song_direction(song_direction: SongDirection):
  91. cachefile_content = get_cachefile_content(const.NEXTSONG_CACHE_FILE)
  92. if song_direction == SongDirection.PREVIOUS:
  93. step = -1
  94. else:
  95. step = 1
  96. if (
  97. not (
  98. len(cachefile_content) == 2
  99. and match(r"[0-9]{4}-[0-9]{2}-[0-9]{2}$", cachefile_content[0])
  100. and match(r"^[0-9]+$", cachefile_content[1])
  101. )
  102. or cachefile_content[0].strip() != get_yyyy_mm_dd_date()
  103. ):
  104. switch_to_song(1)
  105. else:
  106. switch_to_song(int(cachefile_content[1]) + step)
  107. def switch_to_song(song_number: int) -> None:
  108. if song_number > const.OBS_MIN_SUBDIRS:
  109. song_number = 1
  110. if song_number < 1:
  111. song_number = const.OBS_MIN_SUBDIRS
  112. log("sending hotkey to switch to scene {}".format(song_number), "cyan")
  113. scene_switch_hotkey = list(const.OBS_SWITCH_TO_SCENE_HOTKEY_PREFIX)
  114. scene_switch_hotkey.append("f{}".format(song_number))
  115. safe_send_hotkey(scene_switch_hotkey)
  116. log("sending hotkey to transition to scene {}".format(song_number), "cyan")
  117. safe_send_hotkey(const.OBS_TRANSITION_HOTKEY)
  118. create_cachfile_for_song(song_number)
  119. def safe_send_hotkey(hotkey: list, sleep_time=0.1) -> None:
  120. for key in hotkey:
  121. keyDown(key)
  122. sleep(sleep_time)
  123. for key in hotkey:
  124. keyUp(key)
  125. def create_cachfile_for_song(song) -> None:
  126. log("writing song {} to cachefile...".format(song))
  127. try:
  128. with open(
  129. const.NEXTSONG_CACHE_FILE, mode="w", encoding="utf-8-sig"
  130. ) as file_writer:
  131. file_writer.write(get_yyyy_mm_dd_date() + "\n")
  132. file_writer.write(str(song) + "\n")
  133. except (FileNotFoundError, PermissionError, IOError) as error:
  134. error_msg(
  135. "Failed to write to cachefile '{}'. Reason: {}".format(
  136. const.NEXTSONG_CACHE_FILE, error
  137. )
  138. )
  139. def mark_end_of_recording(cachefile_content: list) -> None:
  140. cachefile = expand_dir(const.CD_RECORD_CACHEFILE)
  141. log("marking end of recording...")
  142. try:
  143. with open(cachefile, mode="w+", encoding="utf-8-sig") as file_writer:
  144. file_writer.write(cachefile_content[0].strip() + "\n")
  145. file_writer.write("9001\n")
  146. file_writer.write(cachefile_content[2].strip() + "\n")
  147. file_writer.write(cachefile_content[3].strip() + "\n")
  148. file_writer.write(cachefile_content[4].strip() + "\n")
  149. file_writer.write(cachefile_content[5].strip() + "\n")
  150. except (FileNotFoundError, PermissionError, IOError) as error:
  151. error_msg(
  152. "Failed to write to cachefile '{}'. Reason: {}".format(
  153. cachefile, error
  154. )
  155. )
  156. def is_valid_cd_record_checkfile(
  157. cachefile_content: list, yyyy_mm_dd: str
  158. ) -> bool:
  159. return (
  160. len(cachefile_content) == 6
  161. # YYYY-MM-DD
  162. and bool(match(r"[0-9]{4}-[0-9]{2}-[0-9]{2}$", cachefile_content[0]))
  163. # last set marker
  164. and bool(match(r"^[0-9][0-9]?$", cachefile_content[1]))
  165. # pid of ffmpeg recording instance
  166. and bool(match(r"^[0-9]+$", cachefile_content[2]))
  167. # unix milis @ recording start
  168. and bool(match(r"^[0-9]+$", cachefile_content[3]))
  169. # unix milis @ last track
  170. and bool(match(r"^[0-9]+$", cachefile_content[4]))
  171. # cd number
  172. and bool(match(r"^[0-9]+$", cachefile_content[5]))
  173. # date matches today
  174. and cachefile_content[0].strip() == yyyy_mm_dd
  175. )
  176. class CDBurnerGUI:
  177. def __init__(self, cd_drive: str, yyyy_mm_dd: str, cd_num: str):
  178. self.app = QApplication([])
  179. self.drive = cd_drive
  180. self.yyyy_mm_dd = yyyy_mm_dd
  181. self.cd_num = cd_num
  182. self.exit_code = 1
  183. self.show_burning_msg_box()
  184. self.start_burn_subprocess()
  185. self.app.exec_()
  186. def burning_successful(self) -> bool:
  187. if self.exit_code == 0:
  188. return True
  189. return False
  190. def show_burning_msg_box(self):
  191. self.message_box = QMessageBox()
  192. self.message_box.setWindowTitle("Info")
  193. self.message_box.setText("Burning CD...")
  194. self.message_box.setInformativeText(
  195. "Please wait for a few minutes. You can close this Window, as "
  196. + "there will spawn another window after the operation is "
  197. + "finished."
  198. )
  199. self.message_box.show()
  200. def start_burn_subprocess(self):
  201. process = Popen(
  202. split(get_burn_cmd(self.drive, self.yyyy_mm_dd, self.cd_num))
  203. )
  204. while process.poll() is None:
  205. QApplication.processEvents()
  206. self.message_box.accept()
  207. # Yeah this is hacky but it doesn't work when calling quit directly
  208. QTimer.singleShot(0, self.app.quit)
  209. self.exit_code = process.returncode
  210. def burn_cds_of_day(yyyy_mm_dd: str) -> None:
  211. validate_cd_record_config()
  212. make_sure_file_exists(const.CD_RECORD_CACHEFILE)
  213. try:
  214. target_dir = path.join(
  215. expand_dir(const.CD_RECORD_OUTPUT_BASEDIR), yyyy_mm_dd
  216. )
  217. if not path.isdir(target_dir):
  218. exit_as_no_cds_found(target_dir)
  219. target_files = sorted(listdir(target_dir))
  220. cue_sheets = []
  221. for file in target_files:
  222. if is_legal_sheet_filename(file):
  223. cue_sheets.append(file)
  224. if not target_files:
  225. exit_as_no_cds_found(target_dir)
  226. if len(cue_sheets) == 1:
  227. burn_and_eject_cd(
  228. yyyy_mm_dd, "1".zfill(const.CD_RECORD_FILENAME_ZFILL)
  229. )
  230. else:
  231. app = QApplication([])
  232. dialog = SheetAndPreviewChooser(
  233. target_dir, cue_sheets, f"Preview CD's for {yyyy_mm_dd}"
  234. )
  235. if dialog.exec_() == QDialog.Accepted:
  236. if not dialog.chosen_sheets:
  237. sys.exit(0)
  238. log(f"Burning CD's from sheets: {dialog.chosen_sheets}")
  239. num_of_chosen_sheets = len(dialog.chosen_sheets)
  240. for num, sheet in enumerate(dialog.chosen_sheets):
  241. del app # pyright: ignore
  242. last_cd_to_burn = num == num_of_chosen_sheets
  243. burn_and_eject_cd(
  244. yyyy_mm_dd,
  245. get_padded_cd_num_from_sheet_filename(sheet),
  246. last_cd_to_burn,
  247. )
  248. except (FileNotFoundError, PermissionError, IOError):
  249. InfoMsgBox(
  250. QMessageBox.Critical,
  251. "Error",
  252. "Error: Could not access directory: "
  253. + f"'{const.CD_RECORD_OUTPUT_BASEDIR}'",
  254. )
  255. sys.exit(1)
  256. def exit_as_no_cds_found(target_dir):
  257. InfoMsgBox(
  258. QMessageBox.Critical,
  259. "Error",
  260. f"Error: Did not find any CD's in: {target_dir}.",
  261. )
  262. sys.exit(1)
  263. def is_legal_sheet_filename(filename: str) -> bool:
  264. return bool(match(r"^sheet-[0-9]+\.cue", filename)) and len(filename) == 17
  265. def get_padded_cd_num_from_sheet_filename(filename: str) -> str:
  266. if not is_legal_sheet_filename(filename):
  267. InfoMsgBox(
  268. QMessageBox.Critical,
  269. "Error",
  270. f"Error: filename '{filename}' in illegal format",
  271. )
  272. sys.exit(1)
  273. return filename[6:13]
  274. def burn_and_eject_cd(
  275. yyyy_mm_dd: str, padded_cd_num: str, expect_next_cd=False
  276. ) -> None:
  277. cd_drives = get_cd_drives()
  278. if not cd_drives:
  279. InfoMsgBox(
  280. QMessageBox.Critical,
  281. "Error",
  282. "Error: Could not find a CD-ROM. Please try again",
  283. )
  284. sys.exit(1)
  285. drive = choose_right_cd_drive(cd_drives)
  286. burn_success = CDBurnerGUI(
  287. drive, yyyy_mm_dd, padded_cd_num
  288. ).burning_successful()
  289. if expect_next_cd:
  290. extra_success_msg = "Please put the next CD into the drive slot before clicking the button."
  291. else:
  292. extra_success_msg = ""
  293. if burn_success:
  294. InfoMsgBox(
  295. QMessageBox.Info,
  296. "Info",
  297. "Successfully burned CD." + extra_success_msg,
  298. )
  299. else:
  300. InfoMsgBox(QMessageBox.Critical, "Error", "Error: Failed to burn CD.")
  301. eject_drive(drive)
  302. def make_sure_there_is_no_ongoing_cd_recording() -> None:
  303. if path.isfile(const.CD_RECORD_CACHEFILE):
  304. cachefile_content = get_cachefile_content(const.CD_RECORD_CACHEFILE)
  305. if is_valid_cd_record_checkfile(
  306. cachefile_content, get_yyyy_mm_dd_date()
  307. ):
  308. if cachefile_content[1].strip() != "9001":
  309. InfoMsgBox(
  310. QMessageBox.Critical,
  311. "Error",
  312. "Error: Ongoing CD Recording detected",
  313. )
  314. sys.exit(1)
  315. def get_index_line_as_frames(line: str) -> int:
  316. stripped_line = line.strip()
  317. frames = 75 * 60 * int(stripped_line[9:11])
  318. frames += 75 * int(stripped_line[12:14])
  319. frames += int(stripped_line[15:17])
  320. return frames
  321. @dataclass
  322. class SermonSegment:
  323. start_frame: int
  324. end_frame: int
  325. source_cue_sheet: str
  326. source_marker: int
  327. def get_segments_over_20_mins(
  328. segments: list[SermonSegment],
  329. ) -> list[SermonSegment]:
  330. suitable_segments = []
  331. for segment in segments:
  332. if segment.end_frame - segment.start_frame >= 2250: # 75 * 60 * 20
  333. # if segment.end_frame - segment.start_frame >= 90000: # 75 * 60 * 20
  334. suitable_segments.append(segment)
  335. return suitable_segments
  336. def get_possible_sermon_segments_of_day(yyyy_mm_dd: str) -> list[SermonSegment]:
  337. try:
  338. segments = []
  339. base_frames = 0
  340. max_frames = 0
  341. day_dir = path.join(const.CD_RECORD_OUTPUT_BASEDIR, yyyy_mm_dd)
  342. files = sorted(listdir(day_dir))
  343. cue_sheets = []
  344. for file in files:
  345. if is_legal_sheet_filename(file):
  346. cue_sheets.append(file)
  347. for sheet_num, sheet in enumerate(cue_sheets):
  348. with open(
  349. path.join(day_dir, sheet),
  350. mode="r",
  351. encoding="utf-8-sig",
  352. ) as sheet_reader:
  353. sheet_content = sheet_reader.readlines()
  354. start_frame = 0
  355. end_frame = 0
  356. wav_path = ""
  357. max_line_num = 0
  358. for line_num, line in enumerate(sheet_content):
  359. max_line_num = line_num
  360. if line_num == 0:
  361. if not match(r"^FILE \".+\" WAVE$", line):
  362. raise CustomException("invalid first cue sheet line")
  363. wav_path = line[line.find('"') + 1 :]
  364. wav_path = wav_path[: wav_path.rfind('"')]
  365. elif match(r"^\s+INDEX 01 ([0-9]{2}:){2}[0-9]{2}\s*$", line):
  366. if line_num != 2:
  367. end_frame = get_index_line_as_frames(line)
  368. segments.append(
  369. SermonSegment(
  370. start_frame,
  371. end_frame,
  372. sheet,
  373. (max_line_num - 2) // 2,
  374. )
  375. )
  376. start_frame = end_frame
  377. segments.append(
  378. SermonSegment(
  379. start_frame,
  380. get_wave_duration_in_frames(wav_path),
  381. sheet,
  382. max_line_num // 2,
  383. )
  384. )
  385. # for segment in file_segments:
  386. # log(f"start {segment.start_frame}")
  387. # log(f"end {segment.end_frame}")
  388. # log(f"sheet {segment.source_cue_sheet}")
  389. # log(f"marker {segment.source_marker}")
  390. return segments
  391. except (
  392. FileNotFoundError,
  393. PermissionError,
  394. IOError,
  395. CustomException,
  396. ) as error:
  397. InfoMsgBox(
  398. QMessageBox.Critical,
  399. "Error",
  400. f"Error: Could not parse sermon segments. Reason: {error}",
  401. )
  402. sys.exit(1)
  403. def upload_sermon_segment(segment: SermonSegment) -> None:
  404. try:
  405. session = ftplib.FTP_TLS(
  406. const.SERMON_UPLOAD_FTP_HOSTNAME,
  407. const.SERMON_UPLOAD_FTP_USER,
  408. const.SERMON_UPLOAD_FTP_PASSWORD,
  409. )
  410. session.cwd(const.SERMON_UPLOAD_FTP_UPLOAD_DIR)
  411. raw_filenames = session.nlst()
  412. disallowed_filenames = []
  413. for filename in raw_filenames:
  414. if filename != "." and filename != "..":
  415. disallowed_filenames.append(filename)
  416. print(disallowed_filenames)
  417. # file = open("upl.mp3", "rb")
  418. # session.storbinary("STOR upl.mp3", file)
  419. # file.close()
  420. session.quit()
  421. InfoMsgBox(
  422. QMessageBox.Information, "Success", "Sermon uploaded successfully."
  423. )
  424. except ftplib.all_errors as error:
  425. InfoMsgBox(
  426. QMessageBox.Critical,
  427. "Error",
  428. f"Error: Could not connect to ftp server. Reason: {error}",
  429. )
  430. sys.exit(1)
  431. @dataclass
  432. class ArchiveTypeStrings:
  433. archive_type_plural: str
  434. action_to_choose: str
  435. action_ing_form: str
  436. def choose_cd_day() -> list[str]:
  437. strings = ArchiveTypeStrings("CD's", "CD day to Burn", "Burning CD for day")
  438. return choose_archive_day(strings)
  439. def choose_sermon_day() -> list[str]:
  440. strings = ArchiveTypeStrings(
  441. "Sermons", "Sermon day to upload", "Uploading Sermon for day"
  442. )
  443. return choose_archive_day(strings)
  444. def choose_archive_day(strings: ArchiveTypeStrings) -> list[str]:
  445. # pylint: disable=unused-variable
  446. app = QApplication([])
  447. try:
  448. dirs = sorted(listdir(const.CD_RECORD_OUTPUT_BASEDIR))
  449. dirs.reverse()
  450. if not dirs:
  451. return [
  452. f"Did not find any {strings.archive_type_plural} in: "
  453. + f"{const.CD_RECORD_OUTPUT_BASEDIR}.",
  454. "",
  455. ]
  456. dialog = RadioButtonDialog(
  457. dirs, "Choose a " + f"{strings.action_to_choose}"
  458. )
  459. if dialog.exec_() == QDialog.Accepted:
  460. log(f"{strings.action_ing_form} for day: {dialog.chosen}")
  461. return ["", dialog.chosen]
  462. return ["ignore", ""]
  463. except (FileNotFoundError, PermissionError, IOError):
  464. pass
  465. return [
  466. f"Failed to access directory: {const.CD_RECORD_OUTPUT_BASEDIR}.",
  467. "",
  468. ]
  469. def upload_sermon_for_day(yyyy_mm_dd: str):
  470. segments = get_possible_sermon_segments_of_day(yyyy_mm_dd)
  471. suitable_segments = get_segments_over_20_mins(segments)
  472. for segment in suitable_segments:
  473. print(f"start {segment.start_frame}")
  474. print(f"end {segment.end_frame}")
  475. print(f"sheet {segment.source_cue_sheet}")
  476. print(f"marker {segment.source_marker}")
  477. if not segments:
  478. # TODO: choose
  479. InfoMsgBox(
  480. QMessageBox.Critical, "Error", "Error: no suitable segment found"
  481. )
  482. elif len(segments):
  483. upload_sermon_segment(segments[0])
  484. else:
  485. # TODO: choose
  486. pass