Skip to content

Commit e6d3931

Browse files
tanishqbawejaraivisdejusclaude
authored
Restart every selected transcription with its saved options (#1681)
Co-authored-by: Raivis Dejus <orvils@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 298dbf0 commit e6d3931

6 files changed

Lines changed: 172 additions & 30 deletions

File tree

‎appimage/build-appimage.sh‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ rm -rf "$APPDIR"
4545
mkdir -p "$APPDIR/usr/bin" \
4646
"$APPDIR/usr/share/applications" \
4747
"$APPDIR/usr/share/icons/hicolor/scalable/apps" \
48+
"$APPDIR/usr/share/icons/hicolor/256x256/apps" \
4849
"$APPDIR/usr/share/metainfo"
4950

5051
# Copy entire PyInstaller output into usr/bin/
@@ -87,7 +88,11 @@ MimeType=audio/mpeg;audio/wav;audio/ogg;audio/flac;video/mp4;video/webm;
8788
EOF
8889
cp "$APPDIR/Buzz.desktop" "$APPDIR/usr/share/applications/"
8990

90-
# Icon (SVG at AppDir root + XDG hicolor location)
91+
# Icon (PNG + SVG at AppDir root + XDG hicolor locations). appimagetool builds
92+
# .DirIcon only from a PNG (or XPM) and refuses to package without a valid one.
93+
cp "$PROJECT_DIR/share/icons/io.github.chidiwilliams.Buzz.png" "$APPDIR/Buzz.png"
94+
cp "$PROJECT_DIR/share/icons/io.github.chidiwilliams.Buzz.png" \
95+
"$APPDIR/usr/share/icons/hicolor/256x256/apps/Buzz.png"
9196
cp "$PROJECT_DIR/share/icons/io.github.chidiwilliams.Buzz.svg" "$APPDIR/Buzz.svg"
9297
cp "$PROJECT_DIR/share/icons/io.github.chidiwilliams.Buzz.svg" \
9398
"$APPDIR/usr/share/icons/hicolor/scalable/apps/Buzz.svg"

‎buzz/db/entity/transcription.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ class Transcription(Entity):
1717
model_type: str = ModelType.WHISPER.value
1818
whisper_model_size: str | None = None
1919
hugging_face_model_id: str | None = None
20-
word_level_timings: str | None = None
21-
extract_speech: str | None = None
20+
word_level_timings: int | None = None
21+
extract_speech: int | None = None
2222
language: str | None = None
2323
id: str = field(default_factory=lambda: str(uuid.uuid4()))
2424
error_message: str | None = None

‎buzz/widgets/transcription_tasks_table_widget.py‎

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -316,10 +316,11 @@ def contextMenuEvent(self, event):
316316
# Add transcription actions if a row is selected
317317
selected_rows = self.selectionModel().selectedRows()
318318
if selected_rows:
319-
transcription = self.transcription(selected_rows[0])
320-
321-
# Add restart/continue action for failed/canceled/skipped tasks
322-
if transcription.status in ["failed", "canceled", "skipped"]:
319+
# Add restart/continue action if any selected task failed, was canceled or skipped
320+
if any(
321+
self.transcription(row).status in ["failed", "canceled", "skipped"]
322+
for row in selected_rows
323+
):
323324
restart_action = menu.addAction(_("Restart Transcription"))
324325
restart_action.triggered.connect(self.on_restart_transcription_action)
325326
menu.addSeparator()
@@ -694,35 +695,38 @@ def on_notes_action(self):
694695
self.refresh_all()
695696

696697
def on_restart_transcription_action(self):
697-
"""Restart transcription for failed or canceled tasks"""
698-
selected_rows = self.selectionModel().selectedRows()
699-
if not selected_rows:
698+
"""Restart the selected failed, canceled or skipped transcriptions"""
699+
selected = self.selected_transcriptions()
700+
if not selected:
700701
return
701-
702-
# Get the first selected transcription
703-
transcription = self.transcription(selected_rows[0])
704-
705-
# Check if the task can be restarted
706-
if transcription.status not in ["failed", "canceled", "skipped"]:
702+
703+
# Rows that can't be restarted are left as they are
704+
restartable = [
705+
transcription
706+
for transcription in selected
707+
if transcription.status in ["failed", "canceled", "skipped"]
708+
]
709+
if not restartable:
707710
from PyQt6.QtWidgets import QMessageBox
708711
QMessageBox.information(
709712
self,
710713
_("Cannot Restart"),
711714
_("Only failed, canceled, or skipped transcriptions can be restarted.")
712715
)
713716
return
714-
715-
try:
716-
self.transcription_service.reset_transcription_for_restart(UUID(transcription.id))
717-
self._restart_transcription_task(transcription)
718-
self.refresh_all()
719-
except Exception as e:
720-
from PyQt6.QtWidgets import QMessageBox
721-
QMessageBox.warning(
722-
self,
723-
_("Error"),
724-
_("Failed to restart transcription: {}").format(str(e))
725-
)
717+
718+
for transcription in restartable:
719+
try:
720+
self.transcription_service.reset_transcription_for_restart(UUID(transcription.id))
721+
self._restart_transcription_task(transcription)
722+
except Exception as e:
723+
from PyQt6.QtWidgets import QMessageBox
724+
QMessageBox.warning(
725+
self,
726+
_("Error"),
727+
_("Failed to restart transcription: {}").format(str(e))
728+
)
729+
self.refresh_all()
726730

727731
def _restart_transcription_task(self, transcription):
728732
"""Create a new FileTranscriptionTask and add it to the queue worker"""
@@ -755,8 +759,9 @@ def _restart_transcription_task(self, transcription):
755759
whisper_model_size=whisper_model_size,
756760
hugging_face_model_id=transcription.hugging_face_model_id
757761
),
758-
word_level_timings=transcription.word_level_timings == "1" if transcription.word_level_timings else False,
759-
extract_speech=transcription.extract_speech == "1" if transcription.extract_speech else False,
762+
# SQLite returns these BOOLEAN columns as 1 or 0
763+
word_level_timings=transcription.word_level_timings == 1,
764+
extract_speech=transcription.extract_speech == 1,
760765
initial_prompt="", # Not stored in database, use default
761766
openai_access_token="", # Not stored in database, use default
762767
enable_llm_translation=False, # Not stored in database, use default
17.6 KB
Loading

‎tests/conftest.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import platform
66
import random
77
import string
8+
import weakref
89

910
# Disable the GUI startup update check during tests. The check fires an async
1011
# QNetworkAccessManager HTTPS request which, while in flight, interferes with
@@ -58,6 +59,10 @@ def pytest_sessionfinish(session, exitstatus):
5859

5960
leftovers = []
6061
for obj in gc.get_objects():
62+
# A weak proxy passes isinstance checks for its referent, or raises
63+
# ReferenceError once that is gone; the referent is checked on its own
64+
if isinstance(obj, weakref.ProxyTypes):
65+
continue
6166
try:
6267
if (
6368
isinstance(obj, QObject)

‎tests/widgets/transcription_tasks_table_widget_test.py‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,77 @@ def test_restart_action_service_exception_shows_warning(self, widget, monkeypatc
496496

497497
mock_messagebox.warning.assert_called_once()
498498

499+
# --- restarting several selected rows ---
500+
501+
FILE_ROW_ID = "11111111-1111-1111-1111-111111111111"
502+
URL_ROW_ID = "22222222-2222-2222-2222-222222222222"
503+
504+
def _select_both_rows(self, widget, db, file_row_status, url_row_status):
505+
"""Give both rows a UUID id and a status, then select them"""
506+
query = QSqlQuery(db)
507+
query.exec(
508+
f"UPDATE transcription SET id = '{self.FILE_ROW_ID}', status = '{file_row_status}' WHERE id = '1'"
509+
)
510+
query.exec(
511+
f"UPDATE transcription SET id = '{self.URL_ROW_ID}', status = '{url_row_status}' WHERE id = '2'"
512+
)
513+
widget.refresh_all()
514+
widget.selectAll()
515+
assert len(widget.selectionModel().selectedRows()) == 2
516+
517+
def test_restart_action_restarts_every_selected_row(self, widget, db, monkeypatch):
518+
self._select_both_rows(widget, db, "failed", "canceled")
519+
mock_service = Mock()
520+
widget.transcription_service = mock_service
521+
mock_restart = Mock()
522+
monkeypatch.setattr(widget, "_restart_transcription_task", mock_restart)
523+
524+
widget.on_restart_transcription_action()
525+
526+
reset_ids = [c.args[0] for c in mock_service.reset_transcription_for_restart.call_args_list]
527+
assert sorted(reset_ids) == [UUID(self.FILE_ROW_ID), UUID(self.URL_ROW_ID)]
528+
restarted_ids = [c.args[0].id for c in mock_restart.call_args_list]
529+
assert sorted(restarted_ids) == [self.FILE_ROW_ID, self.URL_ROW_ID]
530+
531+
def test_restart_action_skips_selected_rows_that_cannot_restart(self, widget, db, monkeypatch):
532+
self._select_both_rows(widget, db, "failed", "completed")
533+
mock_service = Mock()
534+
widget.transcription_service = mock_service
535+
mock_restart = Mock()
536+
monkeypatch.setattr(widget, "_restart_transcription_task", mock_restart)
537+
mock_messagebox = Mock()
538+
monkeypatch.setattr("PyQt6.QtWidgets.QMessageBox", mock_messagebox)
539+
540+
widget.on_restart_transcription_action()
541+
542+
mock_service.reset_transcription_for_restart.assert_called_once_with(UUID(self.FILE_ROW_ID))
543+
assert [c.args[0].id for c in mock_restart.call_args_list] == [self.FILE_ROW_ID]
544+
mock_messagebox.information.assert_not_called()
545+
546+
def test_restart_action_shows_message_once_if_no_selected_row_can_restart(self, widget, db, monkeypatch):
547+
self._select_both_rows(widget, db, "completed", "in_progress")
548+
mock_service = Mock()
549+
widget.transcription_service = mock_service
550+
mock_messagebox = Mock()
551+
monkeypatch.setattr("PyQt6.QtWidgets.QMessageBox", mock_messagebox)
552+
553+
widget.on_restart_transcription_action()
554+
555+
mock_service.reset_transcription_for_restart.assert_not_called()
556+
mock_messagebox.information.assert_called_once()
557+
558+
def test_context_menu_offers_restart_if_any_selected_row_can_restart(self, widget, db, monkeypatch):
559+
self._select_both_rows(widget, db, "failed", "completed")
560+
first_selected_row = widget.selectionModel().selectedRows()[0]
561+
assert widget.transcription(first_selected_row).status == "completed"
562+
mock_menu = Mock(spec=QMenu)
563+
monkeypatch.setattr("buzz.widgets.transcription_tasks_table_widget.QMenu", Mock(return_value=mock_menu))
564+
565+
widget.contextMenuEvent(Mock())
566+
567+
labels = [c.args[0] for c in mock_menu.addAction.call_args_list]
568+
assert "Restart Transcription" in labels
569+
499570
# --- _restart_transcription_task ---
500571

501572
def _make_transcription(self, **kwargs):
@@ -729,6 +800,62 @@ def capture_file_transcription_options(**kwargs):
729800
# Only the valid format should be present, invalid one skipped silently
730801
assert len(captured["output_formats"]) == 1
731802

803+
@pytest.mark.parametrize("stored, expected", [(1, True), (0, False), (None, False)])
804+
def test_restart_task_keeps_word_level_timings_and_extract_speech(self, widget, monkeypatch, stored, expected):
805+
# SQLite returns the BOOLEAN columns as 1 or 0
806+
transcription = self._make_transcription(word_level_timings=stored, extract_speech=stored)
807+
808+
mock_model = Mock()
809+
mock_model.get_local_model_path.return_value = "/fake/path"
810+
monkeypatch.setattr("buzz.model_loader.TranscriptionModel", Mock(return_value=mock_model))
811+
mock_task_cls = Mock(return_value=Mock())
812+
monkeypatch.setattr("buzz.transcriber.transcriber.FileTranscriptionTask", mock_task_cls)
813+
mock_main_window = Mock()
814+
mock_main_window.parent.return_value = None
815+
monkeypatch.setattr(widget, "parent", Mock(return_value=mock_main_window))
816+
817+
widget._restart_transcription_task(transcription)
818+
819+
options = mock_task_cls.call_args.kwargs["transcription_options"]
820+
assert options.word_level_timings is expected
821+
assert options.extract_speech is expected
822+
823+
def test_restart_keeps_word_level_timings_and_extract_speech_from_database(self, widget, db, monkeypatch):
824+
from buzz.db.dao.transcription_dao import TranscriptionDAO
825+
from buzz.model_loader import TranscriptionModel
826+
from buzz.transcriber.transcriber import (
827+
FileTranscriptionOptions,
828+
FileTranscriptionTask,
829+
TranscriptionOptions,
830+
)
831+
832+
task = FileTranscriptionTask(
833+
transcription_options=TranscriptionOptions(word_level_timings=True, extract_speech=True),
834+
file_transcription_options=FileTranscriptionOptions(),
835+
model_path="",
836+
file_path="/a/b/word-level.mp3",
837+
)
838+
dao = TranscriptionDAO(db)
839+
dao.create_transcription(task)
840+
dao.update_transcription_as_failed(task.uid, "Error")
841+
widget.refresh_all()
842+
row = next(
843+
row for row in range(widget.model().rowCount())
844+
if widget.model().record(row).value("id") == str(task.uid)
845+
)
846+
widget.selectRow(row)
847+
848+
widget.transcription_service = Mock()
849+
monkeypatch.setattr(TranscriptionModel, "get_local_model_path", lambda self: "/fake/model")
850+
mock_worker = Mock()
851+
monkeypatch.setattr(widget, "parent", Mock(return_value=Mock(transcriber_worker=mock_worker)))
852+
853+
widget.on_restart_transcription_action()
854+
855+
options = mock_worker.add_task.call_args.args[0].transcription_options
856+
assert options.word_level_timings is True
857+
assert options.extract_speech is True
858+
732859
def test_column_resize_event(self, widget):
733860
"""Test column resize event handling"""
734861
# Mock the save_column_widths method

0 commit comments

Comments
 (0)