Răsfoiți Sursa

made same work on preparing wav segment files + restructuring modules

Noah Vogt 1 an în urmă
părinte
comite
82c6ff4d88

+ 22 - 0
audio/__init__.py

@@ -0,0 +1,22 @@
+# Copyright © 2024 Noah Vogt <noah@noahvogt.com>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from .wave import (
+    get_wave_duration_in_secs,
+    get_ffmpeg_timestamp_from_frame,
+    get_wave_duration_in_frames,
+    get_index_line_as_frames,
+)
+from .custom import SermonSegment

+ 24 - 0
audio/custom.py

@@ -0,0 +1,24 @@
+# Copyright © 2024 Noah Vogt <noah@noahvogt.com>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from dataclasses import dataclass
+
+
+@dataclass
+class SermonSegment:
+    start_frame: int
+    end_frame: int
+    source_cue_sheet: str
+    source_marker: int

+ 51 - 0
audio/wave.py

@@ -0,0 +1,51 @@
+# Copyright © 2024 Noah Vogt <noah@noahvogt.com>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+
+from soundfile import SoundFile, LibsndfileError  # pyright: ignore
+from PyQt5.QtWidgets import (  # pylint: disable=no-name-in-module
+    QMessageBox,
+)
+
+
+def get_wave_duration_in_frames(file_name: str) -> int:
+    try:
+        wav = SoundFile(file_name)
+        return int(wav.frames / wav.samplerate * 75)
+    except LibsndfileError:
+        QMessageBox.critical(
+            None,
+            "Error",
+            f"Error: Could not get duration of {file_name}",
+        )
+        sys.exit(1)
+
+
+def get_wave_duration_in_secs(file_name: str) -> int:
+    return int(get_wave_duration_in_frames(file_name) / 75)
+
+
+def get_ffmpeg_timestamp_from_frame(frames: int) -> str:
+    milis = int(frames / 75 * 1000)
+    return f"{milis}ms"
+
+
+def get_index_line_as_frames(line: str) -> int:
+    stripped_line = line.strip()
+    frames = 75 * 60 * int(stripped_line[9:11])
+    frames += 75 * int(stripped_line[12:14])
+    frames += int(stripped_line[15:17])
+    return frames

+ 2 - 1
burn_cds_of_today.py

@@ -15,7 +15,8 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from utils import burn_cds_of_day, get_yyyy_mm_dd_date
+from utils import get_yyyy_mm_dd_date
+from recording import burn_cds_of_day
 
 if __name__ == "__main__":
     burn_cds_of_day(get_yyyy_mm_dd_date())

+ 2 - 4
choose_cd_dialog.py

@@ -19,14 +19,12 @@ from PyQt5.QtWidgets import (  # pylint: disable=no-name-in-module
     QMessageBox,
 )
 
-from utils import (
-    burn_cds_of_day,
-    choose_cd_day,
-)
 from input import (
     validate_cd_record_config,
     InfoMsgBox,
+    choose_cd_day,
 )
+from recording import burn_cds_of_day
 
 
 def choose_and_burn_cd():

+ 2 - 4
choose_sermon_dialog.py

@@ -19,13 +19,11 @@ from PyQt5.QtWidgets import (  # pylint: disable=no-name-in-module
     QMessageBox,
 )
 
-from utils import (
-    upload_sermon_for_day,
-    choose_sermon_day,
-)
+from recording import upload_sermon_for_day
 from input import (
     validate_sermon_upload_config,
     InfoMsgBox,
+    choose_sermon_day,
 )
 
 

+ 7 - 1
input/__init__.py

@@ -31,4 +31,10 @@ from .validate_config import (
     validate_sermon_upload_config,
 )
 from .slide_selection_iterator import slide_selection_iterator
-from .gui import RadioButtonDialog, InfoMsgBox, SheetAndPreviewChooser
+from .gui import (
+    RadioButtonDialog,
+    InfoMsgBox,
+    SheetAndPreviewChooser,
+    choose_cd_day,
+    choose_sermon_day,
+)

+ 51 - 2
input/gui.py

@@ -14,7 +14,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import sys
-from os import path
+from os import path, listdir
 from re import match
 from dataclasses import dataclass
 
@@ -43,7 +43,9 @@ from PyQt5.QtCore import (  # pylint: disable=no-name-in-module
     QTimer,
 )
 
-from utils import CustomException, get_wave_duration_in_secs, SermonSegment
+from audio import SermonSegment
+from utils import CustomException, get_wave_duration_in_secs, log
+import config as const
 
 
 @dataclass
@@ -518,3 +520,50 @@ class SegmentChooser(QDialog):  # pylint: disable=too-few-public-methods
                 f"Could not parse cue sheet: '{full_path}', Reason: {error}",
             )
             sys.exit(1)
+
+@dataclass
+class ArchiveTypeStrings:
+    archive_type_plural: str
+    action_to_choose: str
+    action_ing_form: str
+
+def choose_cd_day() -> list[str]:
+    strings = ArchiveTypeStrings("CD's", "CD day to Burn", "Burning CD for day")
+    return choose_archive_day(strings)
+
+
+def choose_sermon_day() -> list[str]:
+    strings = ArchiveTypeStrings(
+        "Sermons", "Sermon day to upload", "Uploading Sermon for day"
+    )
+    return choose_archive_day(strings)
+
+
+def choose_archive_day(strings: ArchiveTypeStrings) -> list[str]:
+    # pylint: disable=unused-variable
+    app = QApplication([])
+    try:
+        dirs = sorted(listdir(const.CD_RECORD_OUTPUT_BASEDIR))
+        dirs.reverse()
+
+        if not dirs:
+            return [
+                f"Did not find any {strings.archive_type_plural} in: "
+                + f"{const.CD_RECORD_OUTPUT_BASEDIR}.",
+                "",
+            ]
+
+        dialog = RadioButtonDialog(
+            dirs, "Choose a " + f"{strings.action_to_choose}"
+        )
+        if dialog.exec_() == QDialog.Accepted:
+            log(f"{strings.action_ing_form} for day: {dialog.chosen}")
+            return ["", dialog.chosen]
+        return ["ignore", ""]
+    except (FileNotFoundError, PermissionError, IOError):
+        pass
+
+    return [
+        f"Failed to access directory: {const.CD_RECORD_OUTPUT_BASEDIR}.",
+        "",
+    ]

+ 26 - 0
recording/__init__.py

@@ -0,0 +1,26 @@
+# Copyright © 2024 Noah Vogt <noah@noahvogt.com>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from .sermon import (
+    SermonSegment,
+    make_sermon_segment_mp3,
+    prepare_audio_files_for_segment_chooser,
+    upload_sermon_for_day,
+)
+from .verify import (
+    is_valid_cd_record_checkfile,
+    make_sure_there_is_no_ongoing_cd_recording,
+)
+from .cd import mark_end_of_recording, burn_cds_of_day

+ 254 - 0
recording/cd.py

@@ -0,0 +1,254 @@
+# Copyright © 2024 Noah Vogt <noah@noahvogt.com>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+from os import path
+from shlex import split
+from subprocess import Popen
+
+from PyQt5.QtWidgets import (  # pylint: disable=no-name-in-module
+    QDialog,
+    QApplication,
+    QMessageBox,
+)
+from PyQt5.QtCore import (  # pylint: disable=no-name-in-module
+    QTimer,
+)
+
+import config as const
+from input import (
+    InfoMsgBox,
+    RadioButtonDialog,
+    validate_cd_record_config,
+    SheetAndPreviewChooser,
+)
+from utils import expand_dir, log, get_yyyy_mm_dd_date
+from os_agnostic import get_cd_drives, eject_drive
+from .verify import (
+    is_legal_sheet_filename,
+    get_padded_cd_num_from_sheet_filename,
+)
+
+
+def get_burn_cmd(cd_drive: str, yyyy_mm_dd, padded_zfill_num: str) -> str:
+    cue_sheet_path = path.join(
+        expand_dir(const.CD_RECORD_OUTPUT_BASEDIR),
+        yyyy_mm_dd,
+        f"sheet-{padded_zfill_num}.cue",
+    )
+    return (
+        f"cdrecord -pad dev={cd_drive} -dao -swab -text -audio "
+        + f"-cuefile='{cue_sheet_path}'"
+    )
+
+
+class CDBurnerGUI:
+    def __init__(self, cd_drive: str, yyyy_mm_dd: str, cd_num: str) -> None:
+        self.app = QApplication([])
+        self.drive = cd_drive
+        self.yyyy_mm_dd = yyyy_mm_dd
+        self.cd_num = cd_num
+        self.exit_code = 1
+        self.show_burning_msg_box()
+        self.start_burn_subprocess()
+        self.app.exec_()
+
+    def burning_successful(self) -> bool:
+        if self.exit_code == 0:
+            return True
+        return False
+
+    def show_burning_msg_box(self):
+        self.message_box = QMessageBox()
+        self.message_box.setWindowTitle("Info")
+        self.message_box.setText("Burning CD...")
+        self.message_box.setInformativeText(
+            "Please wait for a few minutes. You can close this Window, as "
+            + "there will spawn another window after the operation is "
+            + "finished."
+        )
+
+        self.message_box.show()
+
+    def start_burn_subprocess(self):
+        process = Popen(
+            split(get_burn_cmd(self.drive, self.yyyy_mm_dd, self.cd_num))
+        )
+
+        while process.poll() is None:
+            QApplication.processEvents()
+        self.message_box.accept()
+
+        # Yeah this is hacky but it doesn't work when calling quit directly
+        QTimer.singleShot(0, self.app.quit)
+        self.exit_code = process.returncode
+
+
+def choose_right_cd_drive(drives: list) -> str:
+    if len(drives) != 1:
+        log("Warning: More than one cd drive found", color="yellow")
+        if (
+            const.CD_RECORD_PREFERED_DRIVE in drives
+            and const.CD_RECORD_PREFERED_DRIVE != ""
+        ):
+            return const.CD_RECORD_PREFERED_DRIVE
+
+        dialog = RadioButtonDialog(drives, "Choose a CD to Burn")
+        if dialog.exec_() == QDialog.Accepted:
+            print(f"Dialog accepted: {dialog.chosen_sheets}")
+            return dialog.chosen_sheets
+        log("Warning: Choosing first cd drive...", color="yellow")
+
+    return drives[0]
+
+
+def burn_and_eject_cd(
+    yyyy_mm_dd: str, padded_cd_num: str, expect_next_cd=False
+) -> None:
+    cd_drives = get_cd_drives()
+    if not cd_drives:
+        InfoMsgBox(
+            QMessageBox.Critical,
+            "Error",
+            "Error: Could not find a CD-ROM. Please try again",
+        )
+        sys.exit(1)
+    drive = choose_right_cd_drive(cd_drives)
+
+    burn_success = CDBurnerGUI(
+        drive, yyyy_mm_dd, padded_cd_num
+    ).burning_successful()
+    if expect_next_cd:
+        extra_success_msg = "Please put the next CD into the drive slot before clicking the button."
+    else:
+        extra_success_msg = ""
+    if burn_success:
+        InfoMsgBox(
+            QMessageBox.Info,
+            "Info",
+            "Successfully burned CD." + extra_success_msg,
+        )
+    else:
+        InfoMsgBox(QMessageBox.Critical, "Error", "Error: Failed to burn CD.")
+
+    eject_drive(drive)
+
+
+def burn_cds_of_day(yyyy_mm_dd: str) -> None:
+    validate_cd_record_config()
+    make_sure_file_exists(const.CD_RECORD_CACHEFILE)
+
+    try:
+        target_dir = path.join(
+            expand_dir(const.CD_RECORD_OUTPUT_BASEDIR), yyyy_mm_dd
+        )
+        if not path.isdir(target_dir):
+            exit_as_no_cds_found(target_dir)
+
+        target_files = sorted(listdir(target_dir))
+        cue_sheets = []
+        for file in target_files:
+            if is_legal_sheet_filename(file):
+                cue_sheets.append(file)
+
+        if not target_files:
+            exit_as_no_cds_found(target_dir)
+
+        if len(cue_sheets) == 1:
+            burn_and_eject_cd(
+                yyyy_mm_dd, "1".zfill(const.CD_RECORD_FILENAME_ZFILL)
+            )
+        else:
+            app = QApplication([])
+            dialog = SheetAndPreviewChooser(
+                target_dir, cue_sheets, f"Preview CD's for {yyyy_mm_dd}"
+            )
+            if dialog.exec_() == QDialog.Accepted:
+                if not dialog.chosen_sheets:
+                    sys.exit(0)
+                log(f"Burning CD's from sheets: {dialog.chosen_sheets}")
+                num_of_chosen_sheets = len(dialog.chosen_sheets)
+                for num, sheet in enumerate(dialog.chosen_sheets):
+                    del app  # pyright: ignore
+                    last_cd_to_burn = num == num_of_chosen_sheets
+                    burn_and_eject_cd(
+                        yyyy_mm_dd,
+                        get_padded_cd_num_from_sheet_filename(sheet),
+                        last_cd_to_burn,
+                    )
+
+    except (FileNotFoundError, PermissionError, IOError):
+        InfoMsgBox(
+            QMessageBox.Critical,
+            "Error",
+            "Error: Could not access directory: "
+            + f"'{const.CD_RECORD_OUTPUT_BASEDIR}'",
+        )
+        sys.exit(1)
+
+
+def exit_as_no_cds_found(target_dir):
+    InfoMsgBox(
+        QMessageBox.Critical,
+        "Error",
+        f"Error: Did not find any CD's in: {target_dir}.",
+    )
+    sys.exit(1)
+
+
+def create_cachfile_for_song(song) -> None:
+    log("writing song {} to cachefile...".format(song))
+    try:
+        with open(
+            const.NEXTSONG_CACHE_FILE, mode="w", encoding="utf-8-sig"
+        ) as file_writer:
+            file_writer.write(get_yyyy_mm_dd_date() + "\n")
+            file_writer.write(str(song) + "\n")
+    except (FileNotFoundError, PermissionError, IOError) as error:
+        app = QApplication
+        InfoMsgBox(
+            QMessageBox.Critical,
+            "Error",
+            "Failed to write to cachefile '{}'. Reason: {}".format(
+                const.NEXTSONG_CACHE_FILE, error
+            ),
+        )
+        del app
+        sys.exit(1)
+
+
+def mark_end_of_recording(cachefile_content: list) -> None:
+    cachefile = expand_dir(const.CD_RECORD_CACHEFILE)
+
+    log("marking end of recording...")
+    try:
+        with open(cachefile, mode="w+", encoding="utf-8-sig") as file_writer:
+            file_writer.write(cachefile_content[0].strip() + "\n")
+            file_writer.write("9001\n")
+            file_writer.write(cachefile_content[2].strip() + "\n")
+            file_writer.write(cachefile_content[3].strip() + "\n")
+            file_writer.write(cachefile_content[4].strip() + "\n")
+            file_writer.write(cachefile_content[5].strip() + "\n")
+    except (FileNotFoundError, PermissionError, IOError) as error:
+        app = QApplication
+        InfoMsgBox(
+            QMessageBox.Critical,
+            "Error",
+            "Failed to write to cachefile '{}'. Reason: {}".format(
+                cachefile, error
+            ),
+        )
+        del app
+        sys.exit(1)

+ 300 - 0
recording/sermon.py

@@ -0,0 +1,300 @@
+# Copyright © 2024 Noah Vogt <noah@noahvogt.com>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+from os import path, listdir
+from shlex import split
+from re import match
+from subprocess import Popen
+import ftplib
+from shutil import copy2
+
+from PyQt5.QtWidgets import (  # pylint: disable=no-name-in-module
+    QApplication,
+    QMessageBox,
+    QInputDialog,
+)
+
+from utils import CustomException
+from input import InfoMsgBox
+from audio import (
+    get_ffmpeg_timestamp_from_frame,
+    SermonSegment,
+    get_wave_duration_in_frames,
+    get_index_line_as_frames,
+)
+import config as const
+from .verify import (
+    get_padded_cd_num_from_sheet_filename,
+    is_legal_sheet_filename,
+)
+
+
+def get_full_wav_path(segment: SermonSegment) -> str:
+    try:
+        with open(
+            segment.source_cue_sheet,
+            mode="r",
+            encoding="utf-8-sig",
+        ) as cue_sheet_reader:
+            cue_sheet_content = cue_sheet_reader.readlines()
+        first_line = cue_sheet_content[0].strip()
+        if not match(r"^FILE \".+\" WAVE$", first_line):
+            raise CustomException("invalid first cue sheet line")
+        full_wav_path = first_line[first_line.find('"') + 1 :]
+        return full_wav_path[: full_wav_path.rfind('"')]
+    except (
+        FileNotFoundError,
+        PermissionError,
+        IOError,
+        CustomException,
+    ) as error:
+        app = QApplication([])
+        QMessageBox.critical(
+            None,
+            "Error",
+            f"Could not parse cue sheet: '{segment.source_cue_sheet}',"
+            + f"Reason: {error}",
+        )
+        del app
+        sys.exit(1)
+
+
+def get_audio_base_path_from_segment(segment: SermonSegment) -> str:
+    splitted_sheet_path = path.split(segment.source_cue_sheet)
+    yyyy_mm_dd = path.split(splitted_sheet_path[0])[1]
+    cd_num = get_padded_cd_num_from_sheet_filename(splitted_sheet_path[1])
+    mp3_path = path.join(
+        splitted_sheet_path[0],
+        f"{yyyy_mm_dd}-{cd_num}-segment-{segment.source_marker}",
+    )
+    return mp3_path
+
+
+def make_sermon_segment_mp3(segment: SermonSegment) -> str:
+    full_wav_path = get_full_wav_path(segment)
+
+    mp3_path = f"{get_audio_base_path_from_segment(segment)}.mp3"
+    cmd = "ffmpeg -y -i {} -acodec libmp3lame {}".format(
+        full_wav_path,
+        mp3_path,
+    )
+    process = Popen(split(cmd))
+    _ = process.communicate()[0]  # wait for subprocess to end
+    if process.returncode not in [255, 0]:
+        app = QApplication([])
+        InfoMsgBox(
+            QMessageBox.Critical,
+            "Error",
+            "ffmpeg terminated with " + f"exit code {process.returncode}",
+        )
+        del app
+
+    return mp3_path
+
+
+def prepare_audio_files_for_segment_chooser(
+    segments: list[SermonSegment],
+) -> None:
+    for segment in segments:
+        # TODO: check if file duration and type roughly match the target to
+        # avoid useless regenerating. Also, parallelization.
+        cmd = (
+            f"ffmpeg -y -i {get_full_wav_path(segment)} -ss "
+            + f" {get_ffmpeg_timestamp_from_frame(segment.start_frame)} "
+            + f"-to {get_ffmpeg_timestamp_from_frame(segment.end_frame)} "
+            + f"-acodec copy {get_audio_base_path_from_segment(segment)}.wav"
+        )
+        process = Popen(split(cmd))
+        _ = process.communicate()[0]  # wait for subprocess to end
+        if process.returncode not in [255, 0]:
+            app = QApplication([])
+            InfoMsgBox(
+                QMessageBox.Critical,
+                "Error",
+                "ffmpeg terminated with " + f"exit code {process.returncode}",
+            )
+            del app
+            sys.exit(1)
+
+
+def get_possible_sermon_segments_of_day(yyyy_mm_dd: str) -> list[SermonSegment]:
+    try:
+        segments = []
+        base_frames = 0
+        max_frames = 0
+
+        day_dir = path.join(const.CD_RECORD_OUTPUT_BASEDIR, yyyy_mm_dd)
+        files = sorted(listdir(day_dir))
+        cue_sheets = []
+        for file in files:
+            if is_legal_sheet_filename(file):
+                cue_sheets.append(file)
+        for sheet_num, sheet in enumerate(cue_sheets):
+            with open(
+                path.join(day_dir, sheet),
+                mode="r",
+                encoding="utf-8-sig",
+            ) as sheet_reader:
+                sheet_content = sheet_reader.readlines()
+            start_frame = 0
+            end_frame = 0
+            wav_path = ""
+            max_line_num = 0
+            for line_num, line in enumerate(sheet_content):
+                max_line_num = line_num
+                if line_num == 0:
+                    if not match(r"^FILE \".+\" WAVE$", line):
+                        raise CustomException("invalid first cue sheet line")
+                    wav_path = line[line.find('"') + 1 :]
+                    wav_path = wav_path[: wav_path.rfind('"')]
+                elif match(r"^\s+INDEX 01 ([0-9]{2}:){2}[0-9]{2}\s*$", line):
+                    if line_num != 2:
+                        end_frame = get_index_line_as_frames(line)
+                        segments.append(
+                            SermonSegment(
+                                start_frame,
+                                end_frame,
+                                path.join(day_dir, sheet),
+                                (max_line_num - 2) // 2,
+                            )
+                        )
+                        start_frame = end_frame
+
+            segments.append(
+                SermonSegment(
+                    start_frame,
+                    get_wave_duration_in_frames(wav_path),
+                    path.join(day_dir, sheet),
+                    max_line_num // 2,
+                )
+            )
+
+        return segments
+    except (
+        FileNotFoundError,
+        PermissionError,
+        IOError,
+        CustomException,
+    ) as error:
+        InfoMsgBox(
+            QMessageBox.Critical,
+            "Error",
+            f"Error: Could not parse sermon segments. Reason: {error}",
+        )
+        sys.exit(1)
+
+
+def get_segments_with_suitable_time(
+    segments: list[SermonSegment],
+) -> list[SermonSegment]:
+    suitable_segments = []
+    for segment in segments:
+        if (
+            segment.end_frame - segment.start_frame
+            >= const.SERMON_UPLOAD_SUITABLE_SEGMENT_FRAMES
+        ):
+            # if segment.end_frame - segment.start_frame >= 90000:  # 75 * 60 * 20
+            suitable_segments.append(segment)
+    return suitable_segments
+
+
+def upload_sermon_segment(segment: SermonSegment) -> None:
+    try:
+        session = ftplib.FTP_TLS(
+            const.SERMON_UPLOAD_FTP_HOSTNAME,
+            const.SERMON_UPLOAD_FTP_USER,
+            const.SERMON_UPLOAD_FTP_PASSWORD,
+        )
+        session.cwd(const.SERMON_UPLOAD_FTP_UPLOAD_DIR)
+        raw_filenames = session.nlst()
+        disallowed_filenames = []
+        for filename in raw_filenames:
+            if filename not in (".", ".."):
+                disallowed_filenames.append(filename)
+
+        app = QApplication([])
+        wanted_filename, accepted_dialog = QInputDialog.getText(
+            None,
+            "Input Dialog",
+            "Enter the filename for the Sermon (the .mp3 can be omitted):",
+        )
+        del app
+        if not wanted_filename.endswith(".mp3"):
+            wanted_filename = wanted_filename + ".mp3"
+
+        if not accepted_dialog or wanted_filename == ".mp3":
+            session.quit()
+            sys.exit(0)
+        if wanted_filename in disallowed_filenames:
+            InfoMsgBox(
+                QMessageBox.Critical, "Error", "Error: filename already exists."
+            )
+            session.quit()
+            sys.exit(1)
+
+        orig_mp3 = make_sermon_segment_mp3(segment)
+        mp3_final_path = path.join(path.split(orig_mp3)[0], wanted_filename)
+        copy2(orig_mp3, mp3_final_path)
+
+        with open(mp3_final_path, "rb") as file:
+            session.storbinary(f"STOR {path.split(mp3_final_path)[1]}", file)
+        session.quit()
+        InfoMsgBox(
+            QMessageBox.Information, "Success", "Sermon uploaded successfully."
+        )
+    except (
+        *ftplib.all_errors,
+        FileNotFoundError,
+        PermissionError,
+        IOError,
+    ) as error:
+        InfoMsgBox(
+            QMessageBox.Critical,
+            "Error",
+            f"Error: Could not connect to ftp server. Reason: {error}",
+        )
+        sys.exit(1)
+
+
+def upload_sermon_for_day(yyyy_mm_dd: str):
+    segments = get_possible_sermon_segments_of_day(yyyy_mm_dd)
+    if not segments:
+        InfoMsgBox(
+            QMessageBox.Critical,
+            "Error",
+            f"Error: No segment for day '{yyyy_mm_dd}' found",
+        )
+        sys.exit(1)
+
+    suitable_segments = get_segments_with_suitable_time(segments)
+    # TODO: remove
+    # for segment in suitable_segments:
+    #     print(f"start {segment.start_frame}")
+    #     print(f"end {segment.end_frame}")
+    #     print(f"sheet {segment.source_cue_sheet}")
+    #     print(f"marker {segment.source_marker}")
+
+    if not suitable_segments:
+        # TODO: choose
+        prepare_audio_files_for_segment_chooser(segments)
+        InfoMsgBox(
+            QMessageBox.Critical, "Error", "Error: no suitable segment found"
+        )
+    elif len(suitable_segments) == 1:
+        upload_sermon_segment(suitable_segments[0])
+    else:
+        # TODO: choose
+        pass

+ 79 - 0
recording/verify.py

@@ -0,0 +1,79 @@
+# Copyright © 2024 Noah Vogt <noah@noahvogt.com>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+from os import path
+from re import match
+
+from PyQt5.QtWidgets import (  # pylint: disable=no-name-in-module
+    QMessageBox,
+)
+
+import config as const
+from utils import get_yyyy_mm_dd_date
+from input import InfoMsgBox, get_cachefile_content
+
+
+def is_valid_cd_record_checkfile(
+    cachefile_content: list, yyyy_mm_dd: str
+) -> bool:
+    return (
+        len(cachefile_content) == 6
+        # YYYY-MM-DD
+        and bool(match(r"[0-9]{4}-[0-9]{2}-[0-9]{2}$", cachefile_content[0]))
+        # last set marker
+        and bool(match(r"^[0-9][0-9]?$", cachefile_content[1]))
+        # pid of ffmpeg recording instance
+        and bool(match(r"^[0-9]+$", cachefile_content[2]))
+        # unix milis @ recording start
+        and bool(match(r"^[0-9]+$", cachefile_content[3]))
+        # unix milis @ last track
+        and bool(match(r"^[0-9]+$", cachefile_content[4]))
+        # cd number
+        and bool(match(r"^[0-9]+$", cachefile_content[5]))
+        # date matches today
+        and cachefile_content[0].strip() == yyyy_mm_dd
+    )
+
+
+def make_sure_there_is_no_ongoing_cd_recording() -> None:
+    if path.isfile(const.CD_RECORD_CACHEFILE):
+        cachefile_content = get_cachefile_content(const.CD_RECORD_CACHEFILE)
+        if is_valid_cd_record_checkfile(
+            cachefile_content, get_yyyy_mm_dd_date()
+        ):
+            if cachefile_content[1].strip() != "9001":
+                InfoMsgBox(
+                    QMessageBox.Critical,
+                    "Error",
+                    "Error: Ongoing CD Recording detected",
+                )
+                sys.exit(1)
+
+
+def is_legal_sheet_filename(filename: str) -> bool:
+    return bool(match(r"^sheet-[0-9]+\.cue", filename)) and len(filename) == 17
+
+
+def get_padded_cd_num_from_sheet_filename(filename: str) -> str:
+    if not is_legal_sheet_filename(filename):
+        InfoMsgBox(
+            QMessageBox.Critical,
+            "Error",
+            f"Error: filename '{filename}' in illegal format",
+        )
+        sys.exit(1)
+
+    return filename[6:13]

+ 1 - 2
set_cd_marker.py

@@ -23,16 +23,15 @@ from re import match
 from utils import (
     get_yyyy_mm_dd_date,
     make_sure_file_exists,
-    is_valid_cd_record_checkfile,
     get_unix_milis,
     log,
     warn,
     error_msg,
     expand_dir,
-    mark_end_of_recording,
 )
 from input import get_cachefile_content, validate_cd_record_config
 import config as const
+from recording import is_valid_cd_record_checkfile, mark_end_of_recording
 
 
 def get_reset_marker(yyyy_mm_dd: str) -> int:

+ 1 - 2
stop_cd_recording.py

@@ -22,14 +22,13 @@ from time import sleep
 from utils import (
     get_yyyy_mm_dd_date,
     make_sure_file_exists,
-    is_valid_cd_record_checkfile,
     error_msg,
-    mark_end_of_recording,
     get_unix_milis,
     warn,
 )
 from input import get_cachefile_content, validate_cd_record_config
 import config as const
+from recording import is_valid_cd_record_checkfile, mark_end_of_recording
 
 
 def stop_cd_recording() -> None:

+ 3 - 1
upload_sermon_of_today.py

@@ -16,8 +16,10 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from utils import (
-    make_sure_there_is_no_ongoing_cd_recording,
     get_yyyy_mm_dd_date,
+)
+from recording import (
+    make_sure_there_is_no_ongoing_cd_recording,
     upload_sermon_for_day,
 )
 from input import (

+ 0 - 11
utils/__init__.py

@@ -28,17 +28,6 @@ from .date import get_yyyy_mm_dd_date, get_unix_milis
 from .scripts import (
     make_sure_file_exists,
     switch_to_song,
-    is_valid_cd_record_checkfile,
-    burn_cds_of_day,
-    mark_end_of_recording,
     cycle_to_song_direction,
     SongDirection,
-    make_sure_there_is_no_ongoing_cd_recording,
-    get_possible_sermon_segments_of_day,
-    get_segments_with_suitable_time,
-    upload_sermon_segment,
-    choose_cd_day,
-    choose_sermon_day,
-    upload_sermon_for_day,
-    SermonSegment,
 )

+ 13 - 592
utils/scripts.py

@@ -15,42 +15,26 @@
 
 import sys
 from os import path, listdir
-from subprocess import Popen
-from shlex import split
 from time import sleep
 from re import match
 from enum import Enum
 from dataclasses import dataclass
-import ftplib
-from shutil import copy2
 
 from pyautogui import keyDown, keyUp
 from PyQt5.QtWidgets import (  # pylint: disable=no-name-in-module
     QApplication,
     QMessageBox,
-    QInputDialog,
 )
 from PyQt5.QtWidgets import (  # pylint: disable=no-name-in-module
     QDialog,
 )
-from PyQt5.QtCore import QTimer  # pylint: disable=no-name-in-module
-
 from utils import (
     log,
     error_msg,
     get_yyyy_mm_dd_date,
     expand_dir,
-    CustomException,
-    get_wave_duration_in_frames,
-)
-from input import (
-    validate_cd_record_config,
-    RadioButtonDialog,
-    InfoMsgBox,
-    SheetAndPreviewChooser,
-    get_cachefile_content,
 )
-from os_agnostic import get_cd_drives, eject_drive
+from input import RadioButtonDialog, get_cachefile_content, InfoMsgBox
 import config as const
 
 
@@ -69,36 +53,6 @@ def make_sure_file_exists(cachefile: str) -> None:
             )
 
 
-def choose_right_cd_drive(drives: list) -> str:
-    if len(drives) != 1:
-        log("Warning: More than one cd drive found", color="yellow")
-        if (
-            const.CD_RECORD_PREFERED_DRIVE in drives
-            and const.CD_RECORD_PREFERED_DRIVE != ""
-        ):
-            return const.CD_RECORD_PREFERED_DRIVE
-
-        dialog = RadioButtonDialog(drives, "Choose a CD to Burn")
-        if dialog.exec_() == QDialog.Accepted:
-            print(f"Dialog accepted: {dialog.chosen_sheets}")
-            return dialog.chosen_sheets
-        log("Warning: Choosing first cd drive...", color="yellow")
-
-    return drives[0]
-
-
-def get_burn_cmd(cd_drive: str, yyyy_mm_dd, padded_zfill_num: str) -> str:
-    cue_sheet_path = path.join(
-        expand_dir(const.CD_RECORD_OUTPUT_BASEDIR),
-        yyyy_mm_dd,
-        f"sheet-{padded_zfill_num}.cue",
-    )
-    return (
-        f"cdrecord -pad dev={cd_drive} -dao -swab -text -audio "
-        + f"-cuefile='{cue_sheet_path}'"
-    )
-
-
 class SongDirection(Enum):
     PREVIOUS = "previous"
     NEXT = "next"
@@ -139,562 +93,29 @@ def switch_to_song(song_number: int) -> None:
     create_cachfile_for_song(song_number)
 
 
-def safe_send_hotkey(hotkey: list, sleep_time=0.1) -> None:
-    for key in hotkey:
-        keyDown(key)
-    sleep(sleep_time)
-    for key in hotkey:
-        keyUp(key)
-
-
 def create_cachfile_for_song(song) -> None:
     log("writing song {} to cachefile...".format(song))
+    cachefile = expand_dir(const.NEXTSONG_CACHE_FILE)
     try:
-        with open(
-            const.NEXTSONG_CACHE_FILE, mode="w", encoding="utf-8-sig"
-        ) as file_writer:
+        with open(cachefile, mode="w", encoding="utf-8-sig") as file_writer:
             file_writer.write(get_yyyy_mm_dd_date() + "\n")
             file_writer.write(str(song) + "\n")
     except (FileNotFoundError, PermissionError, IOError) as error:
-        error_msg(
-            "Failed to write to cachefile '{}'. Reason: {}".format(
-                const.NEXTSONG_CACHE_FILE, error
-            )
-        )
-
-
-def mark_end_of_recording(cachefile_content: list) -> None:
-    cachefile = expand_dir(const.CD_RECORD_CACHEFILE)
-
-    log("marking end of recording...")
-    try:
-        with open(cachefile, mode="w+", encoding="utf-8-sig") as file_writer:
-            file_writer.write(cachefile_content[0].strip() + "\n")
-            file_writer.write("9001\n")
-            file_writer.write(cachefile_content[2].strip() + "\n")
-            file_writer.write(cachefile_content[3].strip() + "\n")
-            file_writer.write(cachefile_content[4].strip() + "\n")
-            file_writer.write(cachefile_content[5].strip() + "\n")
-    except (FileNotFoundError, PermissionError, IOError) as error:
-        error_msg(
-            "Failed to write to cachefile '{}'. Reason: {}".format(
-                cachefile, error
-            )
-        )
-
-
-def is_valid_cd_record_checkfile(
-    cachefile_content: list, yyyy_mm_dd: str
-) -> bool:
-    return (
-        len(cachefile_content) == 6
-        # YYYY-MM-DD
-        and bool(match(r"[0-9]{4}-[0-9]{2}-[0-9]{2}$", cachefile_content[0]))
-        # last set marker
-        and bool(match(r"^[0-9][0-9]?$", cachefile_content[1]))
-        # pid of ffmpeg recording instance
-        and bool(match(r"^[0-9]+$", cachefile_content[2]))
-        # unix milis @ recording start
-        and bool(match(r"^[0-9]+$", cachefile_content[3]))
-        # unix milis @ last track
-        and bool(match(r"^[0-9]+$", cachefile_content[4]))
-        # cd number
-        and bool(match(r"^[0-9]+$", cachefile_content[5]))
-        # date matches today
-        and cachefile_content[0].strip() == yyyy_mm_dd
-    )
-
-
-class CDBurnerGUI:
-    def __init__(self, cd_drive: str, yyyy_mm_dd: str, cd_num: str):
-        self.app = QApplication([])
-        self.drive = cd_drive
-        self.yyyy_mm_dd = yyyy_mm_dd
-        self.cd_num = cd_num
-        self.exit_code = 1
-        self.show_burning_msg_box()
-        self.start_burn_subprocess()
-        self.app.exec_()
-
-    def burning_successful(self) -> bool:
-        if self.exit_code == 0:
-            return True
-        return False
-
-    def show_burning_msg_box(self):
-        self.message_box = QMessageBox()
-        self.message_box.setWindowTitle("Info")
-        self.message_box.setText("Burning CD...")
-        self.message_box.setInformativeText(
-            "Please wait for a few minutes. You can close this Window, as "
-            + "there will spawn another window after the operation is "
-            + "finished."
-        )
-
-        self.message_box.show()
-
-    def start_burn_subprocess(self):
-        process = Popen(
-            split(get_burn_cmd(self.drive, self.yyyy_mm_dd, self.cd_num))
-        )
-
-        while process.poll() is None:
-            QApplication.processEvents()
-        self.message_box.accept()
-
-        # Yeah this is hacky but it doesn't work when calling quit directly
-        QTimer.singleShot(0, self.app.quit)
-        self.exit_code = process.returncode
-
-
-def burn_cds_of_day(yyyy_mm_dd: str) -> None:
-    validate_cd_record_config()
-    make_sure_file_exists(const.CD_RECORD_CACHEFILE)
-
-    try:
-        target_dir = path.join(
-            expand_dir(const.CD_RECORD_OUTPUT_BASEDIR), yyyy_mm_dd
-        )
-        if not path.isdir(target_dir):
-            exit_as_no_cds_found(target_dir)
-
-        target_files = sorted(listdir(target_dir))
-        cue_sheets = []
-        for file in target_files:
-            if is_legal_sheet_filename(file):
-                cue_sheets.append(file)
-
-        if not target_files:
-            exit_as_no_cds_found(target_dir)
-
-        if len(cue_sheets) == 1:
-            burn_and_eject_cd(
-                yyyy_mm_dd, "1".zfill(const.CD_RECORD_FILENAME_ZFILL)
-            )
-        else:
-            app = QApplication([])
-            dialog = SheetAndPreviewChooser(
-                target_dir, cue_sheets, f"Preview CD's for {yyyy_mm_dd}"
-            )
-            if dialog.exec_() == QDialog.Accepted:
-                if not dialog.chosen_sheets:
-                    sys.exit(0)
-                log(f"Burning CD's from sheets: {dialog.chosen_sheets}")
-                num_of_chosen_sheets = len(dialog.chosen_sheets)
-                for num, sheet in enumerate(dialog.chosen_sheets):
-                    del app  # pyright: ignore
-                    last_cd_to_burn = num == num_of_chosen_sheets
-                    burn_and_eject_cd(
-                        yyyy_mm_dd,
-                        get_padded_cd_num_from_sheet_filename(sheet),
-                        last_cd_to_burn,
-                    )
-
-    except (FileNotFoundError, PermissionError, IOError):
-        InfoMsgBox(
-            QMessageBox.Critical,
-            "Error",
-            "Error: Could not access directory: "
-            + f"'{const.CD_RECORD_OUTPUT_BASEDIR}'",
-        )
-        sys.exit(1)
-
-
-def get_ffmpeg_timestamp_from_frame(frames: int) -> str:
-    milis = int(frames / 75 * 1000)
-    return f"{milis}ms"
-
-
-def prepare_audio_files_for_segment_chooser(
-    segments: list[SermonSegment],
-) -> None:
-    for segment in segments:
-        # TODO: check if file duration and type roughly match the target to
-        # avoid useless regenerating. Also, parallelization.
-        cmd = (
-            f"ffmpeg -y -i {get_full_wav_path(segment)} -ss "
-            + f" {get_ffmpeg_timestamp_from_frame(segment.start_frame)} "
-            + f"-to {get_ffmpeg_timestamp_from_frame(segment.end_frame)} "
-            + f"-acodec copy {get_audio_base_path_from_segment(segment)}.wav"
-        )
-        process = Popen(split(cmd))
-        _ = process.communicate()[0]  # wait for subprocess to end
-        if process.returncode not in [255, 0]:
-            app = QApplication([])
-            InfoMsgBox(
-                QMessageBox.Critical,
-                "Error",
-                "ffmpeg terminated with " + f"exit code {process.returncode}",
-            )
-            del app
-            sys.exit(1)
-
-
-def exit_as_no_cds_found(target_dir):
-    InfoMsgBox(
-        QMessageBox.Critical,
-        "Error",
-        f"Error: Did not find any CD's in: {target_dir}.",
-    )
-    sys.exit(1)
-
-
-def is_legal_sheet_filename(filename: str) -> bool:
-    return bool(match(r"^sheet-[0-9]+\.cue", filename)) and len(filename) == 17
-
-
-def get_padded_cd_num_from_sheet_filename(filename: str) -> str:
-    if not is_legal_sheet_filename(filename):
-        InfoMsgBox(
-            QMessageBox.Critical,
-            "Error",
-            f"Error: filename '{filename}' in illegal format",
-        )
-        sys.exit(1)
-
-    return filename[6:13]
-
-
-def burn_and_eject_cd(
-    yyyy_mm_dd: str, padded_cd_num: str, expect_next_cd=False
-) -> None:
-    cd_drives = get_cd_drives()
-    if not cd_drives:
-        InfoMsgBox(
-            QMessageBox.Critical,
-            "Error",
-            "Error: Could not find a CD-ROM. Please try again",
-        )
-        sys.exit(1)
-    drive = choose_right_cd_drive(cd_drives)
-
-    burn_success = CDBurnerGUI(
-        drive, yyyy_mm_dd, padded_cd_num
-    ).burning_successful()
-    if expect_next_cd:
-        extra_success_msg = "Please put the next CD into the drive slot before clicking the button."
-    else:
-        extra_success_msg = ""
-    if burn_success:
-        InfoMsgBox(
-            QMessageBox.Info,
-            "Info",
-            "Successfully burned CD." + extra_success_msg,
-        )
-    else:
-        InfoMsgBox(QMessageBox.Critical, "Error", "Error: Failed to burn CD.")
-
-    eject_drive(drive)
-
-
-def make_sure_there_is_no_ongoing_cd_recording() -> None:
-    if path.isfile(const.CD_RECORD_CACHEFILE):
-        cachefile_content = get_cachefile_content(const.CD_RECORD_CACHEFILE)
-        if is_valid_cd_record_checkfile(
-            cachefile_content, get_yyyy_mm_dd_date()
-        ):
-            if cachefile_content[1].strip() != "9001":
-                InfoMsgBox(
-                    QMessageBox.Critical,
-                    "Error",
-                    "Error: Ongoing CD Recording detected",
-                )
-                sys.exit(1)
-
-
-def get_index_line_as_frames(line: str) -> int:
-    stripped_line = line.strip()
-    frames = 75 * 60 * int(stripped_line[9:11])
-    frames += 75 * int(stripped_line[12:14])
-    frames += int(stripped_line[15:17])
-    return frames
-
-
-@dataclass
-class SermonSegment:
-    start_frame: int
-    end_frame: int
-    source_cue_sheet: str
-    source_marker: int
-
-
-def get_segments_with_suitable_time(
-    segments: list[SermonSegment],
-) -> list[SermonSegment]:
-    suitable_segments = []
-    for segment in segments:
-        if (
-            segment.end_frame - segment.start_frame
-            >= const.SERMON_UPLOAD_SUITABLE_SEGMENT_FRAMES
-        ):
-            # if segment.end_frame - segment.start_frame >= 90000:  # 75 * 60 * 20
-            suitable_segments.append(segment)
-    return suitable_segments
-
-
-def get_possible_sermon_segments_of_day(yyyy_mm_dd: str) -> list[SermonSegment]:
-    try:
-        segments = []
-        base_frames = 0
-        max_frames = 0
-
-        day_dir = path.join(const.CD_RECORD_OUTPUT_BASEDIR, yyyy_mm_dd)
-        files = sorted(listdir(day_dir))
-        cue_sheets = []
-        for file in files:
-            if is_legal_sheet_filename(file):
-                cue_sheets.append(file)
-        for sheet_num, sheet in enumerate(cue_sheets):
-            with open(
-                path.join(day_dir, sheet),
-                mode="r",
-                encoding="utf-8-sig",
-            ) as sheet_reader:
-                sheet_content = sheet_reader.readlines()
-            start_frame = 0
-            end_frame = 0
-            wav_path = ""
-            max_line_num = 0
-            for line_num, line in enumerate(sheet_content):
-                max_line_num = line_num
-                if line_num == 0:
-                    if not match(r"^FILE \".+\" WAVE$", line):
-                        raise CustomException("invalid first cue sheet line")
-                    wav_path = line[line.find('"') + 1 :]
-                    wav_path = wav_path[: wav_path.rfind('"')]
-                elif match(r"^\s+INDEX 01 ([0-9]{2}:){2}[0-9]{2}\s*$", line):
-                    if line_num != 2:
-                        end_frame = get_index_line_as_frames(line)
-                        segments.append(
-                            SermonSegment(
-                                start_frame,
-                                end_frame,
-                                path.join(day_dir, sheet),
-                                (max_line_num - 2) // 2,
-                            )
-                        )
-                        start_frame = end_frame
-
-            segments.append(
-                SermonSegment(
-                    start_frame,
-                    get_wave_duration_in_frames(wav_path),
-                    path.join(day_dir, sheet),
-                    max_line_num // 2,
-                )
-            )
-            # for segment in file_segments:
-            #     log(f"start {segment.start_frame}")
-            #     log(f"end {segment.end_frame}")
-            #     log(f"sheet {segment.source_cue_sheet}")
-            #     log(f"marker {segment.source_marker}")
-
-        return segments
-    except (
-        FileNotFoundError,
-        PermissionError,
-        IOError,
-        CustomException,
-    ) as error:
+        app = QApplication
         InfoMsgBox(
             QMessageBox.Critical,
             "Error",
-            f"Error: Could not parse sermon segments. Reason: {error}",
-        )
-        sys.exit(1)
-
-
-def get_full_wav_path(segment: SermonSegment) -> str:
-    try:
-        with open(
-            segment.source_cue_sheet,
-            mode="r",
-            encoding="utf-8-sig",
-        ) as cue_sheet_reader:
-            cue_sheet_content = cue_sheet_reader.readlines()
-        first_line = cue_sheet_content[0].strip()
-        if not match(r"^FILE \".+\" WAVE$", first_line):
-            raise CustomException("invalid first cue sheet line")
-        full_wav_path = first_line[first_line.find('"') + 1 :]
-        return full_wav_path[: full_wav_path.rfind('"')]
-    except (
-        FileNotFoundError,
-        PermissionError,
-        IOError,
-        CustomException,
-    ) as error:
-        app = QApplication([])
-        QMessageBox.critical(
-            None,
-            "Error",
-            f"Could not parse cue sheet: '{segment.source_cue_sheet}',"
-            + f"Reason: {error}",
-        )
-        del app
-        sys.exit(1)
-
-
-def make_sermon_segment_mp3(segment: SermonSegment) -> str:
-    full_wav_path = get_full_wav_path(segment)
-
-    mp3_path = f"{get_audio_base_path_from_segment(segment)}.mp3"
-    cmd = "ffmpeg -y -i {} -acodec libmp3lame {}".format(
-        full_wav_path,
-        mp3_path,
-    )
-    process = Popen(split(cmd))
-    _ = process.communicate()[0]  # wait for subprocess to end
-    if process.returncode not in [255, 0]:
-        app = QApplication([])
-        InfoMsgBox(
-            QMessageBox.Critical,
-            "Error",
-            "ffmpeg terminated with " + f"exit code {process.returncode}",
-        )
-        del app
-
-    return mp3_path
-
-
-def get_audio_base_path_from_segment(segment: SermonSegment) -> str:
-    splitted_sheet_path = path.split(segment.source_cue_sheet)
-    mp3_path = path.join(
-        splitted_sheet_path[0],
-        splitted_sheet_path[1][6:13] + f"-segment-{segment.source_marker}",
-    )
-    return mp3_path
-
-
-def upload_sermon_segment(segment: SermonSegment) -> None:
-    try:
-        session = ftplib.FTP_TLS(
-            const.SERMON_UPLOAD_FTP_HOSTNAME,
-            const.SERMON_UPLOAD_FTP_USER,
-            const.SERMON_UPLOAD_FTP_PASSWORD,
-        )
-        session.cwd(const.SERMON_UPLOAD_FTP_UPLOAD_DIR)
-        raw_filenames = session.nlst()
-        disallowed_filenames = []
-        for filename in raw_filenames:
-            if filename not in (".", ".."):
-                disallowed_filenames.append(filename)
-
-        app = QApplication([])
-        wanted_filename, accepted_dialog = QInputDialog.getText(
-            None,
-            "Input Dialog",
-            "Enter the filename for the Sermon (the .mp3 can be omitted):",
+            "Failed to write to cachefile '{}'. Reason: {}".format(
+                cachefile, error
+            ),
         )
         del app
-        if not wanted_filename.endswith(".mp3"):
-            wanted_filename = wanted_filename + ".mp3"
-
-        if not accepted_dialog or wanted_filename == ".mp3":
-            session.quit()
-            sys.exit(0)
-        if wanted_filename in disallowed_filenames:
-            InfoMsgBox(
-                QMessageBox.Critical, "Error", "Error: filename already exists."
-            )
-            session.quit()
-            sys.exit(1)
-
-        orig_mp3 = make_sermon_segment_mp3(segment)
-        mp3_final_path = path.join(path.split(orig_mp3)[0], wanted_filename)
-        copy2(orig_mp3, mp3_final_path)
-
-        with open(mp3_final_path, "rb") as file:
-            session.storbinary(f"STOR {path.split(mp3_final_path)[1]}", file)
-        session.quit()
-        InfoMsgBox(
-            QMessageBox.Information, "Success", "Sermon uploaded successfully."
-        )
-    except (
-        *ftplib.all_errors,
-        FileNotFoundError,
-        PermissionError,
-        IOError,
-    ) as error:
-        InfoMsgBox(
-            QMessageBox.Critical,
-            "Error",
-            f"Error: Could not connect to ftp server. Reason: {error}",
-        )
         sys.exit(1)
 
 
-@dataclass
-class ArchiveTypeStrings:
-    archive_type_plural: str
-    action_to_choose: str
-    action_ing_form: str
-
-
-def choose_cd_day() -> list[str]:
-    strings = ArchiveTypeStrings("CD's", "CD day to Burn", "Burning CD for day")
-    return choose_archive_day(strings)
-
-
-def choose_sermon_day() -> list[str]:
-    strings = ArchiveTypeStrings(
-        "Sermons", "Sermon day to upload", "Uploading Sermon for day"
-    )
-    return choose_archive_day(strings)
-
-
-def choose_archive_day(strings: ArchiveTypeStrings) -> list[str]:
-    # pylint: disable=unused-variable
-    app = QApplication([])
-    try:
-        dirs = sorted(listdir(const.CD_RECORD_OUTPUT_BASEDIR))
-        dirs.reverse()
-
-        if not dirs:
-            return [
-                f"Did not find any {strings.archive_type_plural} in: "
-                + f"{const.CD_RECORD_OUTPUT_BASEDIR}.",
-                "",
-            ]
-
-        dialog = RadioButtonDialog(
-            dirs, "Choose a " + f"{strings.action_to_choose}"
-        )
-        if dialog.exec_() == QDialog.Accepted:
-            log(f"{strings.action_ing_form} for day: {dialog.chosen}")
-            return ["", dialog.chosen]
-        return ["ignore", ""]
-    except (FileNotFoundError, PermissionError, IOError):
-        pass
-
-    return [
-        f"Failed to access directory: {const.CD_RECORD_OUTPUT_BASEDIR}.",
-        "",
-    ]
-
-
-def upload_sermon_for_day(yyyy_mm_dd: str):
-    segments = get_possible_sermon_segments_of_day(yyyy_mm_dd)
-    if not segments:
-        InfoMsgBox(
-            QMessageBox.Critical,
-            "Error",
-            f"Error: No segment for day '{yyyy_mm_dd}' found",
-        )
-
-    suitable_segments = get_segments_with_suitable_time(segments)
-    for segment in suitable_segments:
-        print(f"start {segment.start_frame}")
-        print(f"end {segment.end_frame}")
-        print(f"sheet {segment.source_cue_sheet}")
-        print(f"marker {segment.source_marker}")
-
-    if not suitable_segments:
-        # TODO: choose
-        InfoMsgBox(
-            QMessageBox.Critical, "Error", "Error: no suitable segment found"
-        )
-    elif len(suitable_segments) == 1:
-        upload_sermon_segment(suitable_segments[0])
-    else:
-        # TODO: choose
-        pass
+def safe_send_hotkey(hotkey: list, sleep_time=0.1) -> None:
+    for key in hotkey:
+        keyDown(key)
+    sleep(sleep_time)
+    for key in hotkey:
+        keyUp(key)