scripts.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  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 pyautogui import keyDown, keyUp
  22. from PyQt5.QtWidgets import ( # pylint: disable=no-name-in-module
  23. QApplication,
  24. QMessageBox,
  25. )
  26. from PyQt5.QtWidgets import ( # pylint: disable=no-name-in-module
  27. QDialog,
  28. )
  29. from PyQt5.QtCore import QTimer # pylint: disable=no-name-in-module
  30. from utils import (
  31. log,
  32. error_msg,
  33. get_yyyy_mm_dd_date,
  34. expand_dir,
  35. )
  36. from input import (
  37. validate_cd_record_config,
  38. RadioButtonDialog,
  39. InfoMsgBox,
  40. SheetAndPreviewChooser,
  41. get_cachefile_content,
  42. )
  43. from os_agnostic import get_cd_drives, eject_drive
  44. import config as const
  45. def make_sure_file_exists(cachefile: str) -> None:
  46. if not path.isfile(cachefile):
  47. try:
  48. with open(
  49. cachefile, mode="w+", encoding="utf-8-sig"
  50. ) as file_creator:
  51. file_creator.write("")
  52. except (FileNotFoundError, PermissionError, IOError) as error:
  53. error_msg(
  54. "Failed to create file in '{}'. Reason: {}".format(
  55. cachefile, error
  56. )
  57. )
  58. def choose_right_cd_drive(drives: list) -> str:
  59. if len(drives) != 1:
  60. log("Warning: More than one cd drive found", color="yellow")
  61. if (
  62. const.CD_RECORD_PREFERED_DRIVE in drives
  63. and const.CD_RECORD_PREFERED_DRIVE != ""
  64. ):
  65. return const.CD_RECORD_PREFERED_DRIVE
  66. dialog = RadioButtonDialog(drives, "Choose a CD to Burn")
  67. if dialog.exec_() == QDialog.Accepted:
  68. print(f"Dialog accepted: {dialog.chosen_sheets}")
  69. return dialog.chosen_sheets
  70. log("Warning: Choosing first cd drive...", color="yellow")
  71. return drives[0]
  72. def get_burn_cmd(cd_drive: str, yyyy_mm_dd, padded_zfill_num: str) -> str:
  73. cue_sheet_path = path.join(
  74. expand_dir(const.CD_RECORD_OUTPUT_BASEDIR),
  75. yyyy_mm_dd,
  76. f"sheet-{padded_zfill_num}.cue",
  77. )
  78. return (
  79. f"cdrecord -pad dev={cd_drive} -dao -swab -text -audio "
  80. + f"-cuefile='{cue_sheet_path}'"
  81. )
  82. class SongDirection(Enum):
  83. PREVIOUS = "previous"
  84. NEXT = "next"
  85. def cycle_to_song_direction(song_direction: SongDirection):
  86. cachefile_content = get_cachefile_content(const.NEXTSONG_CACHE_FILE)
  87. if song_direction == SongDirection.PREVIOUS:
  88. step = -1
  89. else:
  90. step = 1
  91. if (
  92. not (
  93. len(cachefile_content) == 2
  94. and match(r"[0-9]{4}-[0-9]{2}-[0-9]{2}$", cachefile_content[0])
  95. and match(r"^[0-9]+$", cachefile_content[1])
  96. )
  97. or cachefile_content[0].strip() != get_yyyy_mm_dd_date()
  98. ):
  99. switch_to_song(1)
  100. else:
  101. switch_to_song(int(cachefile_content[1]) + step)
  102. def switch_to_song(song_number: int) -> None:
  103. if song_number > const.OBS_MIN_SUBDIRS:
  104. song_number = 1
  105. if song_number < 1:
  106. song_number = const.OBS_MIN_SUBDIRS
  107. log("sending hotkey to switch to scene {}".format(song_number), "cyan")
  108. scene_switch_hotkey = list(const.OBS_SWITCH_TO_SCENE_HOTKEY_PREFIX)
  109. scene_switch_hotkey.append("f{}".format(song_number))
  110. safe_send_hotkey(scene_switch_hotkey)
  111. log("sending hotkey to transition to scene {}".format(song_number), "cyan")
  112. safe_send_hotkey(const.OBS_TRANSITION_HOTKEY)
  113. create_cachfile_for_song(song_number)
  114. def safe_send_hotkey(hotkey: list, sleep_time=0.1) -> None:
  115. for key in hotkey:
  116. keyDown(key)
  117. sleep(sleep_time)
  118. for key in hotkey:
  119. keyUp(key)
  120. def create_cachfile_for_song(song) -> None:
  121. log("writing song {} to cachefile...".format(song))
  122. try:
  123. with open(
  124. const.NEXTSONG_CACHE_FILE, mode="w", encoding="utf-8-sig"
  125. ) as file_writer:
  126. file_writer.write(get_yyyy_mm_dd_date() + "\n")
  127. file_writer.write(str(song) + "\n")
  128. except (FileNotFoundError, PermissionError, IOError) as error:
  129. error_msg(
  130. "Failed to write to cachefile '{}'. Reason: {}".format(
  131. const.NEXTSONG_CACHE_FILE, error
  132. )
  133. )
  134. def mark_end_of_recording(cachefile_content: list) -> None:
  135. cachefile = expand_dir(const.CD_RECORD_CACHEFILE)
  136. log("marking end of recording...")
  137. try:
  138. with open(cachefile, mode="w+", encoding="utf-8-sig") as file_writer:
  139. file_writer.write(cachefile_content[0].strip() + "\n")
  140. file_writer.write("9001\n")
  141. file_writer.write(cachefile_content[2].strip() + "\n")
  142. file_writer.write(cachefile_content[3].strip() + "\n")
  143. file_writer.write(cachefile_content[4].strip() + "\n")
  144. file_writer.write(cachefile_content[5].strip() + "\n")
  145. except (FileNotFoundError, PermissionError, IOError) as error:
  146. error_msg(
  147. "Failed to write to cachefile '{}'. Reason: {}".format(
  148. cachefile, error
  149. )
  150. )
  151. def is_valid_cd_record_checkfile(
  152. cachefile_content: list, yyyy_mm_dd: str
  153. ) -> bool:
  154. return (
  155. len(cachefile_content) == 6
  156. # YYYY-MM-DD
  157. and bool(match(r"[0-9]{4}-[0-9]{2}-[0-9]{2}$", cachefile_content[0]))
  158. # last set marker
  159. and bool(match(r"^[0-9][0-9]?$", cachefile_content[1]))
  160. # pid of ffmpeg recording instance
  161. and bool(match(r"^[0-9]+$", cachefile_content[2]))
  162. # unix milis @ recording start
  163. and bool(match(r"^[0-9]+$", cachefile_content[3]))
  164. # unix milis @ last track
  165. and bool(match(r"^[0-9]+$", cachefile_content[4]))
  166. # cd number
  167. and bool(match(r"^[0-9]+$", cachefile_content[5]))
  168. # date matches today
  169. and cachefile_content[0].strip() == yyyy_mm_dd
  170. )
  171. class CDBurnerGUI:
  172. def __init__(self, cd_drive: str, yyyy_mm_dd: str, cd_num: str):
  173. self.app = QApplication([])
  174. self.drive = cd_drive
  175. self.yyyy_mm_dd = yyyy_mm_dd
  176. self.cd_num = cd_num
  177. self.exit_code = 1
  178. self.show_burning_msg_box()
  179. self.start_burn_subprocess()
  180. self.app.exec_()
  181. def burning_successful(self) -> bool:
  182. if self.exit_code == 0:
  183. return True
  184. return False
  185. def show_burning_msg_box(self):
  186. self.message_box = QMessageBox()
  187. self.message_box.setWindowTitle("Info")
  188. self.message_box.setText("Burning CD...")
  189. self.message_box.setInformativeText(
  190. "Please wait for a few minutes. You can close this Window, as "
  191. + "there will spawn another window after the operation is "
  192. + "finished."
  193. )
  194. self.message_box.show()
  195. def start_burn_subprocess(self):
  196. process = Popen(
  197. split(get_burn_cmd(self.drive, self.yyyy_mm_dd, self.cd_num))
  198. )
  199. while process.poll() is None:
  200. QApplication.processEvents()
  201. self.message_box.accept()
  202. # Yeah this is hacky but it doesn't work when calling quit directly
  203. QTimer.singleShot(0, self.app.quit)
  204. self.exit_code = process.returncode
  205. def burn_cds_of_day(yyyy_mm_dd: str) -> None:
  206. validate_cd_record_config()
  207. make_sure_file_exists(const.CD_RECORD_CACHEFILE)
  208. try:
  209. target_dir = path.join(
  210. expand_dir(const.CD_RECORD_OUTPUT_BASEDIR), yyyy_mm_dd
  211. )
  212. if not path.isdir(target_dir):
  213. exit_as_no_cds_found(target_dir)
  214. target_files = sorted(listdir(target_dir))
  215. cue_sheets = []
  216. for file in target_files:
  217. if is_legal_sheet_filename(file):
  218. cue_sheets.append(file)
  219. if not target_files:
  220. exit_as_no_cds_found(target_dir)
  221. if len(cue_sheets) == 1:
  222. burn_and_eject_cd(
  223. yyyy_mm_dd, "1".zfill(const.CD_RECORD_FILENAME_ZFILL)
  224. )
  225. else:
  226. app = QApplication([])
  227. dialog = SheetAndPreviewChooser(
  228. target_dir, cue_sheets, f"Preview CD's for {yyyy_mm_dd}"
  229. )
  230. if dialog.exec_() == QDialog.Accepted:
  231. if not dialog.chosen_sheets:
  232. sys.exit(0)
  233. log(f"Burning CD's from sheets: {dialog.chosen_sheets}")
  234. num_of_chosen_sheets = len(dialog.chosen_sheets)
  235. for num, sheet in enumerate(dialog.chosen_sheets):
  236. del app # pyright: ignore
  237. last_cd_to_burn = num == num_of_chosen_sheets
  238. burn_and_eject_cd(
  239. yyyy_mm_dd,
  240. get_padded_cd_num_from_sheet_filename(sheet),
  241. last_cd_to_burn,
  242. )
  243. except (FileNotFoundError, PermissionError, IOError):
  244. InfoMsgBox(
  245. QMessageBox.Critical,
  246. "Error",
  247. "Error: Could not access directory: "
  248. + f"'{const.CD_RECORD_OUTPUT_BASEDIR}'",
  249. )
  250. sys.exit(1)
  251. def exit_as_no_cds_found(target_dir):
  252. InfoMsgBox(
  253. QMessageBox.Critical,
  254. "Error",
  255. f"Error: Did not find any CD's in: {target_dir}.",
  256. )
  257. sys.exit(1)
  258. def is_legal_sheet_filename(filename: str) -> bool:
  259. return bool(match(r"^sheet-[0-9]+\.cue", filename)) and len(filename) == 17
  260. def get_padded_cd_num_from_sheet_filename(filename: str) -> str:
  261. if not is_legal_sheet_filename(filename):
  262. InfoMsgBox(
  263. QMessageBox.Critical,
  264. "Error",
  265. f"Error: filename '{filename}' in illegal format",
  266. )
  267. sys.exit(1)
  268. return filename[6:13]
  269. def burn_and_eject_cd(
  270. yyyy_mm_dd: str, padded_cd_num: str, expect_next_cd=False
  271. ) -> None:
  272. cd_drives = get_cd_drives()
  273. if not cd_drives:
  274. InfoMsgBox(
  275. QMessageBox.Critical,
  276. "Error",
  277. "Error: Could not find a CD-ROM. Please try again",
  278. )
  279. sys.exit(1)
  280. drive = choose_right_cd_drive(cd_drives)
  281. burn_success = CDBurnerGUI(
  282. drive, yyyy_mm_dd, padded_cd_num
  283. ).burning_successful()
  284. if expect_next_cd:
  285. extra_success_msg = "Please put the next CD into the drive slot before clicking the button."
  286. else:
  287. extra_success_msg = ""
  288. if burn_success:
  289. InfoMsgBox(
  290. QMessageBox.Info,
  291. "Info",
  292. "Successfully burned CD." + extra_success_msg,
  293. )
  294. else:
  295. InfoMsgBox(QMessageBox.Critical, "Error", "Error: Failed to burn CD.")
  296. eject_drive(drive)