Forráskód Böngészése

update README + started work on segment chooser gui

Noah Vogt 1 éve
szülő
commit
195b1a9472
4 módosított fájl, 255 hozzáadás és 9 törlés
  1. 2 1
      README.md
  2. 198 1
      input/gui.py
  3. 1 0
      utils/__init__.py
  4. 54 7
      utils/scripts.py

+ 2 - 1
README.md

@@ -374,11 +374,12 @@ These are some issues and possible changes that will be addressed or at least co
 - add tests
 - use smarter multi slide splitter algorithm: either by pattern recognition like line matching or rhymes of the last word or by incorporating some sort of sub-song-structures in the body.
 - add warnings that indicate potential problems with rclone syncing
-- use obs websocket connection, cleaner that transition hotkeys
+- Use obs websocket connection, cleaner that transition hotkeys. Also to deprecate pyautogui.
 - add (semantic) versioning, maybe even display on program run as text
 - add not-yet-public streaming workflow scripts
 - make sermon uploads for more than one suitable segment possible
 - make combined sermon segments possible
+- make multiplaform ejecting of cd drives possible
 - split up `util/scripts.py` in more source files
 
 ## Licensing

+ 198 - 1
input/gui.py

@@ -43,7 +43,7 @@ from PyQt5.QtCore import (  # pylint: disable=no-name-in-module
     QTimer,
 )
 
-from utils import CustomException, get_wave_duration_in_secs
+from utils import CustomException, get_wave_duration_in_secs, SermonSegment
 
 
 @dataclass
@@ -321,3 +321,200 @@ class SheetAndPreviewChooser(QDialog):  # pylint: disable=too-few-public-methods
                 f"Could not parse cue sheet: '{full_path}', Reason: {error}",
             )
             sys.exit(1)
+
+
+class SegmentChooser(QDialog):  # pylint: disable=too-few-public-methods
+    def __init__(
+        self, base_dir: str, options: list[SermonSegment], window_title: str
+    ) -> None:
+        super().__init__()
+        self.base_dir = base_dir
+        self.setWindowTitle(window_title)
+
+        self.master_layout = QVBoxLayout(self)
+
+        scroll_area_layout = self.get_scroll_area_layout()
+
+        self.check_buttons = []
+
+        self.player = QMediaPlayer()
+
+        self.chosen_sheets = []
+        self.position_labels = []
+        for num, item in enumerate(options):
+            rel_wave_path = self.get_wav_relative_path_from_cue_sheet(item)
+
+            check_box = QCheckBox(item)
+            if num == 0:
+                check_box.setChecked(True)
+            button_layout = QHBoxLayout()
+            check_box_construct = CheckBoxConstruct(item, check_box)
+            self.check_buttons.append(check_box_construct)
+            button_layout.addWidget(check_box)
+
+            play_button = self.get_player_button("SP_MediaPlay")
+            play_button.setToolTip("Play CD Preview")
+            play_button.clicked.connect(
+                lambda _, x=rel_wave_path: self.play_audio(x)
+            )
+
+            pause_button = self.get_player_button("SP_MediaPause")
+            pause_button.setToolTip("Pause CD Preview")
+            pause_button.clicked.connect(
+                lambda _, x=rel_wave_path: self.pause_player(x)
+            )
+
+            stop_button = self.get_player_button("SP_MediaStop")
+            stop_button.setToolTip("Stop CD Preview")
+            stop_button.clicked.connect(
+                lambda _, x=rel_wave_path: self.stop_player(x)
+            )
+
+            seek_bwd_button = self.get_player_button("SP_MediaSeekBackward")
+            seek_bwd_button.setToolTip("Seek 10 seconds backwards")
+            seek_bwd_button.clicked.connect(
+                lambda _, x=rel_wave_path: self.seek_bwd_10secs(x)
+            )
+
+            seek_fwd_button = self.get_player_button("SP_MediaSeekForward")
+            seek_fwd_button.setToolTip("Seek 10 seconds forwards")
+            seek_fwd_button.clicked.connect(
+                lambda _, x=rel_wave_path: self.seek_fwd_10secs(x)
+            )
+
+            button_layout.addWidget(play_button)
+            button_layout.addWidget(pause_button)
+            button_layout.addWidget(stop_button)
+            button_layout.addWidget(seek_bwd_button)
+            button_layout.addWidget(seek_fwd_button)
+
+            secs = get_wave_duration_in_secs(path.join(base_dir, rel_wave_path))
+            mins = secs // 60
+            secs %= 60
+            time_label = QLabel(f"00:00 / {mins:02}:{secs:02}")
+            label_construct = LabelConstruct(rel_wave_path, time_label)
+            self.position_labels.append(label_construct)
+            button_layout.addWidget(time_label)
+            timer = QTimer(self)
+            timer.timeout.connect(self.update_position)
+            timer.start(1000)
+
+            scroll_area_layout.addLayout(button_layout)
+
+        ok_button = QPushButton("OK")
+        ok_button.clicked.connect(self.accept)
+        self.master_layout.addWidget(ok_button)
+
+    def play_audio(self, rel_path: str) -> None:
+        if self.player.state() == QMediaPlayer.PausedState:  # pyright: ignore
+            media = self.player.media()
+            if media.isNull():
+                return
+            if path.split(media.canonicalUrl().toString())[1] == rel_path:
+                self.player.play()
+        else:
+            url = QUrl.fromLocalFile(path.join(self.base_dir, rel_path))
+            content = QMediaContent(url)
+            self.player.setMedia(content)
+            self.player.play()
+
+    def update_position(self) -> None:
+        media = self.player.media()
+        if media.isNull():
+            return
+        playing_path = path.split(media.canonicalUrl().toString())[1]
+        for label_construct in self.position_labels:
+            if label_construct.rel_path == playing_path:
+                old_text = label_construct.label.text()
+                old_text = old_text[old_text.find(" / ") :]
+                secs = self.player.position() // 1000
+                mins = secs // 60
+                secs %= 60
+                label_construct.label.setText(f"{mins:02}:{secs:02}{old_text}")
+
+    def stop_player(self, rel_path: str) -> None:
+        media = self.player.media()
+        if media.isNull():
+            return
+        if path.split(media.canonicalUrl().toString())[1] == rel_path:
+            self.player.stop()
+            self.update_position()
+
+    def seek_by(self, rel_path: str, seek_by_milis) -> None:
+        media = self.player.media()
+        if media.isNull():
+            return
+        if path.split(media.canonicalUrl().toString())[1] == rel_path:
+            position = self.player.position()
+            self.player.setPosition(position + seek_by_milis)
+            self.update_position()
+
+    def seek_fwd_10secs(self, rel_path: str) -> None:
+        self.seek_by(rel_path, 10000)
+
+    def seek_bwd_10secs(self, rel_path: str) -> None:
+        self.seek_by(rel_path, -10000)
+
+    def pause_player(self, rel_path: str) -> None:
+        media = self.player.media()
+        if media.isNull():
+            return
+        if path.split(media.canonicalUrl().toString())[1] == rel_path:
+            self.player.pause()
+
+    def get_scroll_area_layout(self):
+        scroll_area = QScrollArea()
+        scroll_area.setWidgetResizable(True)
+        self.master_layout.addWidget(scroll_area)
+
+        scroll_content = QWidget()
+        scroll_area.setWidget(scroll_content)
+        scroll_area_layout = QVBoxLayout(scroll_content)
+        return scroll_area_layout
+
+    def get_player_button(self, icon_name: str) -> QPushButton:
+        stop_button = QPushButton("")
+        stop_button.setMinimumSize(QSize(40, 40))
+        stop_button.setMaximumSize(QSize(40, 40))
+        pixmapi = getattr(QStyle, icon_name)
+        icon = self.style().standardIcon(pixmapi)  # pyright: ignore
+        stop_button.setIcon(icon)
+        return stop_button
+
+    def accept(self) -> None:
+        for check_box_construct in self.check_buttons:
+            if check_box_construct.check_box.isChecked():
+                self.chosen_sheets.append(check_box_construct.rel_path)
+        super().accept()
+
+    def get_wav_relative_path_from_cue_sheet(
+        self, sheet_relative_path: str
+    ) -> str:
+        full_path = path.join(self.base_dir, sheet_relative_path)
+
+        try:
+            with open(
+                full_path,
+                mode="r",
+                encoding="utf-8-sig",
+            ) as cachefile_reader:
+                cachefile_content = cachefile_reader.readlines()
+            first_line = cachefile_content[0].strip()
+            if not match(r"^FILE \".+\" WAVE$", first_line):
+                raise CustomException("invalid first cue sheet line")
+            full_path_found = first_line[first_line.find('"') + 1 :]
+            full_path_found = full_path_found[: full_path_found.rfind('"')]
+            return path.split(full_path_found)[1]
+        except (
+            FileNotFoundError,
+            PermissionError,
+            IOError,
+            IndexError,
+            CustomException,
+        ) as error:
+            QMessageBox.critical(
+                self,
+                "Error",
+                f"Could not parse cue sheet: '{full_path}', Reason: {error}",
+            )
+            sys.exit(1)

+ 1 - 0
utils/__init__.py

@@ -40,4 +40,5 @@ from .scripts import (
     choose_cd_day,
     choose_sermon_day,
     upload_sermon_for_day,
+    SermonSegment,
 )

+ 54 - 7
utils/scripts.py

@@ -300,6 +300,36 @@ def burn_cds_of_day(yyyy_mm_dd: str) -> None:
         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,
@@ -474,7 +504,7 @@ def get_possible_sermon_segments_of_day(yyyy_mm_dd: str) -> list[SermonSegment]:
         sys.exit(1)
 
 
-def make_sermon_segment_mp3(segment: SermonSegment) -> str:
+def get_full_wav_path(segment: SermonSegment) -> str:
     try:
         with open(
             segment.source_cue_sheet,
@@ -486,7 +516,7 @@ def make_sermon_segment_mp3(segment: SermonSegment) -> str:
         if not match(r"^FILE \".+\" WAVE$", first_line):
             raise CustomException("invalid first cue sheet line")
         full_wav_path = first_line[first_line.find('"') + 1 :]
-        full_wav_path = full_wav_path[: full_wav_path.rfind('"')]
+        return full_wav_path[: full_wav_path.rfind('"')]
     except (
         FileNotFoundError,
         PermissionError,
@@ -503,11 +533,11 @@ def make_sermon_segment_mp3(segment: SermonSegment) -> str:
         del app
         sys.exit(1)
 
-    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}.mp3",
-    )
+
+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,
@@ -521,10 +551,20 @@ def make_sermon_segment_mp3(segment: SermonSegment) -> str:
             "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(
@@ -634,6 +674,13 @@ def choose_archive_day(strings: ArchiveTypeStrings) -> list[str]:
 
 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}")