Skip to content

Add PySide6 GUI with video browser and full settings - #44

Merged
MrDix merged 19 commits into
mainfrom
feat/pyside6-gui
Mar 19, 2026
Merged

Add PySide6 GUI with video browser and full settings#44
MrDix merged 19 commits into
mainfrom
feat/pyside6-gui

Conversation

@MrDix

@MrDix MrDix commented Mar 18, 2026

Copy link
Copy Markdown
Owner

Summary

  • Complete PySide6 desktop GUI for UltraSinger with sidebar navigation and dark theme
  • Embedded video browser (QWebEngineView) with persistent login, cookie extraction, and floating "Convert" overlay
  • All CLI parameters exposed as form controls grouped in card-based sections
  • Real-time conversion log with color-coded output, stage detection, and cancel support
  • Cookie management: auto-export Netscape format for yt-dlp premium downloads
  • Docker Compose configuration for GPU-accelerated CLI usage
  • GUI is fully optional: uv sync --extra gui to install, no impact on CLI usage

Architecture

src/gui/
  main_window.py          # QMainWindow with sidebar navigation
  browser_tab.py          # Video browser + Convert overlay
  settings_tab.py         # All conversion parameters (card layout)
  queue_tab.py            # Log output + progress tracking
  preferences_tab.py      # App settings, LLM config, cookies
  cookie_manager.py       # QWebEngineCookieStore → Netscape export
  ultrasinger_runner.py   # QThread subprocess wrapper
  config.py               # JSON config persistence (~/.ultrasinger/)
  secrets.py              # System keyring integration for API keys
  widgets/                # Toggle switch, sidebar, log viewer, etc.
  resources/styles.qss    # Comprehensive dark theme stylesheet

Key Features

Feature Details
Video Browser Persistent QWebEngineProfile, navigation bar, cookie status indicator
Convert Flow Browser → Settings → Queue (auto-start)
Cookie Export Automatic cookie capture → Netscape cookies.txt for yt-dlp
Settings Whisper, Language, Post-Processing, LLM, Output, Device, Paths
LLM Config API setup guide, secure keyring storage, model selection
Log Viewer Color-coded (info/warn/error/success), auto-scroll, monospace font
File Input Drag-and-drop zone + file picker for local audio/video files
Dark Theme Modern dark aesthetic with pink accent, custom-styled all widgets
Docker GPU-enabled compose config with input/output volume mounts

Install & Launch

uv sync --extra gui
uv run python src/gui_main.py

Test plan

  • All 261 existing tests pass (no changes to CLI code)
  • All modules compile without errors
  • Headless smoke test: MainWindow initializes correctly
  • Manual: Launch GUI, browse videos, click Convert
  • Manual: Local file drag-and-drop workflow
  • Manual: Verify cookie export after login
  • Manual: Start conversion and verify log output

Complete desktop GUI for UltraSinger with:

- **YouTube Browser**: Embedded QWebEngineView with persistent session,
  navigation toolbar, cookie status indicator, and floating "Convert"
  button overlay that captures the current URL
- **Cookie Management**: Automatic extraction from QWebEngineCookieStore,
  Netscape-format export for yt-dlp, persistent browser profile
- **Conversion Settings**: All CLI parameters exposed as form controls,
  grouped in card-based sections (Whisper, Language, Post-Processing,
  LLM Correction, Output, Device, Paths)
- **Queue & Progress**: Real-time log output with color-coded lines,
  stage detection, elapsed timer, cancel support, output folder opener
- **Preferences**: Default output folder, Groq/LLM API configuration
  with setup guide, cookie import/export/clear
- **Custom Widgets**: Toggle switches with animation, sidebar navigation,
  settings cards, drag-and-drop file zone, color-coded log viewer
- **Dark Theme**: Comprehensive QSS stylesheet with modern dark aesthetic,
  pink accent color, custom-styled scrollbars/inputs/buttons
- **Docker Compose**: GPU-enabled docker-compose.yml for CLI usage

Install: `uv sync --extra gui`
Launch:  `cd src && uv run python gui_main.py`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Mar 18, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR introduces a complete PySide6-based graphical user interface for UltraSinger, including a YouTube browser tab with integrated conversion controls, form-based settings and preferences panels, a conversion queue with real-time logging and progress tracking, and secure credential management via system keyring integration.

Changes

Cohort / File(s) Summary
Dependencies & Configuration
pyproject.toml
Added optional gui dependency group with PySide6 and keyring packages.
Core GUI Framework
src/gui_main.py, src/gui/main_window.py
Entry point and main window orchestrating layout, wiring conversion flows, managing config persistence, and coordinating tab views and sidebar navigation.
GUI Tabs
src/gui/settings_tab.py, src/gui/queue_tab.py, src/gui/preferences_tab.py
Form-based tabs for transcription/language/processing settings, conversion progress/logging with stage detection, and output/LLM preferences with cookie export controls.
Browser Integration
src/gui/browser_tab.py, src/gui/cookie_manager.py
YouTube-embedded browser with persistent conversion overlay, navigation interception, and Netscape-format cookie export with video detection.
Widget Components
src/gui/widgets/*.py, src/gui/widgets/__init__.py
Reusable UI widgets including toggle switch, sidebar with navigation buttons and file drop zone, log viewer with color coding, settings card, animated button, and file drop zone.
Configuration & Secrets Management
src/gui/config.py, src/gui/secrets.py
JSON-based config with atomic writes and restricted permissions, keyring-backed secret storage with environment variable and legacy migration fallbacks.
Conversion Execution
src/gui/ultrasinger_runner.py
Background thread runner for CLI subprocess with output streaming, stage detection, cancellation support, and error handling with redacted logging.
Styling
src/gui/resources/styles.qss
Comprehensive dark-theme stylesheet covering all UI components including sidebar, buttons, inputs, scrollbars, log panel, and browser toolbar.
Package Structure
src/gui/__init__.py
Package initialization with module docstring.
Container & Documentation
README.md, container/README.md, container/compose-cpu.yml, container/compose-gpu.yml, container/docker.md, container/podman.md
Updated documentation with GUI/CLI separation, new Docker Compose configurations for CPU and GPU, removed legacy compose-nogpu file, and refreshed container guides.
Deleted Files
container/compose-nogpu.yml
Removed obsolete Docker Compose configuration file.
Tests
src/test/test_gui_secrets.py
Test coverage for secrets keyring integration, config secret stripping, and directory permission hardening.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Browser as BrowserTab
    participant MainWindow
    participant Settings as SettingsTab
    participant Queue as QueueTab
    participant Runner as UltraSingerRunner
    participant CLI as UltraSinger CLI
    
    User->>Browser: Click YouTube video, press Convert
    Browser->>MainWindow: convert_requested(url)
    MainWindow->>MainWindow: Set video input in sidebar
    
    User->>Settings: Configure transcription/language options
    User->>MainWindow: Click "Start Conversion" button
    MainWindow->>Settings: collect_config()
    MainWindow->>Queue: start_conversion(args, output_folder)
    
    Queue->>Runner: start(args)
    Runner->>CLI: subprocess.Popen(uv run ...)
    
    CLI-->>Runner: stdout/stderr lines
    Runner->>Queue: line_output signal
    Queue->>Queue: append_log(line), detect_stage()
    
    CLI-->>Runner: finished(exit_code)
    Runner->>Queue: finished(exit_code)
    Queue->>Queue: Update status, enable Open Output
    
    Queue-->>User: Display conversion result & logs
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 A GUI blooms in PySide6 spring,
With browsers and buttons and queues that sing,
YouTube conversions now click-to-go,
Dark themes and keyring secrets flow—
No CLI needed, just point and play! 🎨✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a PySide6 GUI with YouTube browser integration and full settings configuration. It is clear, concise, and directly reflects the primary feature being introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/pyside6-gui
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@MrDix

MrDix commented Mar 18, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 18, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (7)
src/gui/widgets/animated_button.py (1)

1-15: Class name and docstring are misleading.

The class is named AnimatedButton and the docstring mentions "hover animation effects," but there's no animation logic implemented. The actual behavior is setting styling properties (cursor, object name, height). If CSS-based hover transitions are intended via styles.qss, consider renaming to StyledButton or PrimaryButton, or clarifying in the docstring that animations come from the stylesheet.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/widgets/animated_button.py` around lines 1 - 15, The class
AnimatedButton and its docstring claim hover animation but contain no animation
logic; update either the name or the docstring to reflect the actual behavior:
either rename the class (e.g., StyledButton or PrimaryButton) everywhere it’s
used or change the class docstring to state that styling/hover effects are
provided by the app stylesheet (styles.qss) rather than by internal animation
code; adjust references to AnimatedButton in code and tests if renaming, or
update the __init__ docstring and any comments around
setObjectName("primaryButton") and setCursor to make it clear animations are
CSS-driven.
src/gui/widgets/log_viewer.py (1)

5-5: Unused import: QTimer.

QTimer is imported but not used anywhere in this module.

🧹 Remove unused import
-from PySide6.QtCore import Qt, QTimer
+from PySide6.QtCore import Qt
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/widgets/log_viewer.py` at line 5, The import line brings QTimer into
scope but it is unused in this module; remove QTimer from the from
PySide6.QtCore import ... statement (leaving Qt) to clean up the unused import
in log_viewer.py (remove the QTimer token from the import list).
src/gui/widgets/sidebar.py (1)

69-69: Hardcoded version string.

The version "v0.0.13.dev16" is duplicated here and in pyproject.toml. Consider reading from a single source (e.g., importlib.metadata.version("UltraSinger")) to avoid drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/widgets/sidebar.py` at line 69, Replace the hardcoded version string
used to construct version_label in sidebar.py with a single-source lookup — call
importlib.metadata.version("UltraSinger") (or importlib_metadata.version for
older Python) to fetch the package version and use that value when creating the
QLabel (e.g., version_label = QLabel(f"v{version}")). Add a small try/except
fallback to a sensible default like "v0.0.0" if the import or lookup fails so
the GUI still loads. Ensure you update the symbol version_label and any nearby
code that formats or displays the version to use the fetched value instead of
the literal string.
src/gui/settings_tab.py (1)

473-476: Avoid mutating SettingsCard's private layout from the tab.

Line 475 is reaching into another widget's private state and assuming the last item is the row you just added. That is brittle, and the same workaround already exists in PreferencesTab. A small public helper on SettingsCard for labeled custom rows would make these compound rows safe to reuse.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/settings_tab.py` around lines 473 - 476, The tab mutates
SettingsCard's private _layout (card._layout.removeItem(...)) after calling
card.add_row; instead add a public helper on SettingsCard (e.g., add_labeled_row
or add_custom_row) that accepts a label string and a QWidget/layout so callers
like this settings_tab can create the labeled MuseScore row by calling that
helper instead of poking card._layout; update the tab to call the new
SettingsCard method (mirroring the pattern used in PreferencesTab) and remove
the direct access to card._layout and the manual removeItem call.
src/gui/main_window.py (2)

95-98: Avoid reaching into QueueTab private internals from MainWindow.

At Line 95, self._queue_tab._log.append_line(...) couples this class to a private implementation detail. Prefer a public QueueTab method (for example, append_error(...)) and call that instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/main_window.py` around lines 95 - 98, MainWindow is directly
accessing QueueTab's private _log by calling
self._queue_tab._log.append_line(...); introduce a public method on QueueTab
(e.g., append_error or append_line) that accepts the error text and handles
logging, then replace the call in MainWindow to use
self._queue_tab.append_error("No input source selected. Select a YouTube URL or
local file first.") so MainWindow stops depending on _log and uses the public
API of QueueTab.

126-133: Narrow close-time exception handling.

At Line 132, catching Exception is too broad and can mask non-recoverable bugs in settings collection. Catch expected persistence-related exceptions only.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/main_window.py` around lines 126 - 133, The try/except around calling
_settings_tab.collect_config, _preferences_tab.collect_preferences and
save_config is too broad; replace the bare "except Exception" with handling only
expected persistence/parsing errors (e.g., IOError/OSError,
json.JSONDecodeError, ValueError or your app's persistence error) and log those
with logger.warning(..., exc_info=True), but allow other unexpected exceptions
to propagate (or re-raise after logging) so real bugs aren't masked; adjust the
block around collect_config, collect_preferences and save_config accordingly.
src/gui/config.py (1)

71-76: Avoid blind except Exception in config IO paths.

At Line 75 and Line 86, broad catches reduce signal for real defects. Catch expected exception classes (json.JSONDecodeError, TypeError, OSError) instead.

Also applies to: 83-87

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/config.py` around lines 71 - 76, The try/except around opening and
loading _CONFIG_FILE uses a broad except; change it to catch expected errors
only (e.g., json.JSONDecodeError, TypeError, OSError) so real bugs surface: in
the block that opens _CONFIG_FILE, catch those specific exceptions and call
logger.warning(..., exc_info=True) as before, and do the same for the later
load/save block that uses config.update and file operations; reference the
symbols _CONFIG_FILE, json.load, config.update, and logger.warning when making
the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docker-compose.yml`:
- Around line 10-16: The usage examples are resolving against WORKDIR
/app/UltraSinger/src while volumes are mounted at
/app/UltraSinger/{input,output,cookies.txt}; update the example commands that
use relative paths (e.g. -i input/song.mp3, -o output/, --cookiefile
cookies.txt) to use the absolute mount paths (/app/UltraSinger/input/song.mp3,
/app/UltraSinger/output/, /app/UltraSinger/cookies.txt), or alternatively change
the example to run from the repo root by prefixing the command with cd
/app/UltraSinger && ... , or adjust the docker-compose volume mounts to
/app/UltraSinger/src/{input,output,cookies.txt} so the relative paths match the
WORKDIR.

In `@src/gui/browser_tab.py`:
- Around line 143-147: After connecting the cookie signal, call the existing
_update_cookie_status() immediately so the cookie indicator is initialized at
startup; specifically, after
self.cookie_manager.cookies_changed.connect(self._update_cookie_status) invoke
self._update_cookie_status() before loading the URL (the current call to
self._view.setUrl(QUrl("https://www.youtube.com"))).

In `@src/gui/config.py`:
- Line 39: The config currently stores "llm_api_key" and writes the config file
directly (around the write block that writes to the target path), which risks
corruption and exposes secrets; change the save logic to perform an atomic
replace and lock down permissions: write the JSON to a temporary file in the
same directory (e.g., using tempfile.NamedTemporaryFile(delete=False,
dir=os.path.dirname(target_path))), fsync the temp file, os.fchmod or os.chmod
the temp file to 0o600 (owner read/write only) to restrict permissions, then
atomically replace the original with os.replace(temp_path, target_path); also
ensure any temp file is unlinked on error. Locate the code by searching for the
"llm_api_key" key and the block that writes to the target path (the write block
around lines ~80-87) and update that function to use the temp-write + chmod +
os.replace pattern.

In `@src/gui/main_window.py`:
- Around line 86-89: The cookie export calls to
self._browser_tab.cookie_manager.export_netscape(cookie_path) can raise on bad
paths or write failures and must be hardened: wrap each export_netscape
invocation (the ones using cookie_path from self._config and the later export
call guarded by has_youtube_cookies) in a try/except, validate cookie_path is
non-empty (and optionally expand user paths), catch any exceptions, and log them
with logger.error (including the exception info) while returning/continuing so
the navigation/conversion flow does not crash; ensure you reference and update
the code around cookie_path, self._browser_tab.cookie_manager.export_netscape,
has_youtube_cookies, and logger.info/logger.error.

In `@src/gui/preferences_tab.py`:
- Around line 191-199: The _save() method is failing to persist the cookie_file
returned by collect_preferences(): call collect_preferences() (or otherwise
retrieve the cookie_file value returned by collect_preferences()) and assign it
into self._config under the same key used elsewhere (e.g. "cookie_file") before
calling save_config(self._config); update the _save() in preferences_tab (and
the analogous save block around collect_preferences at lines ~201-209) so all
returned values from collect_preferences() are written back into self._config
prior to persisting.

In `@src/gui/settings_tab.py`:
- Around line 438-453: _bpm_override and _octave_shift are free-text QLineEdit
fields that cause type conversion errors downstream (UltraSinger.py expects
--bpm as float and --octave as int); replace them with numeric widgets (use
QDoubleSpinBox for _bpm_override and QSpinBox for _octave_shift) or attach
QDoubleValidator/QIntValidator, set sensible ranges/step/nullable behavior
(e.g., allow empty/None by using a checkbox or special sentinel), and update
collect_config() to return a float for bpm and an int for octave (or None when
empty). Also apply the same change to the equivalent fields referenced later
(the other occurrence mentioned) so command-line args built for --bpm and
--octave are always valid numeric types.

In `@src/gui/ultrasinger_runner.py`:
- Around line 50-52: The command string is being logged with secrets included;
before calling self.line_output.emit in ultrasinger_runner.py (where cmd is
built from _build_command(project_root) + self._args and then joined), create a
redacted copy that masks values for secret flags (e.g., flags named
--llm_api_key, --api_key, --secret, -k, etc.) by replacing the next argv item or
the --flag=value portion with a fixed token like "<REDACTED>", then log the
joined redacted list instead of the raw cmd; update the code paths that build or
log cmd (references: _build_command, self._args, and the self.line_output.emit
call) to use this redacted copy so secrets are never printed.
- Around line 47-56: The run() method must check the cancellation flag before
spawning the subprocess and move any blocking wait/kill logic into a background
thread so the GUI thread is never blocked; specifically, at the start of run()
(after building cmd) return early if self._cancelled is True to avoid spawning
_process, spawn subprocess.Popen into self._process only when not cancelled, and
then start a separate daemon thread (e.g., _monitor_process or similar) to
perform the blocking wait(timeout=5) and subsequent kill() if needed; adjust
cancel() to set self._cancelled = True and, if self._process is already set,
call self._process.kill()/terminate() and let the monitor thread handle the
wait/cleanup so cancel() itself does not perform any blocking operations.

In `@src/gui/widgets/file_drop_zone.py`:
- Around line 19-25: The FileDropZone widget is not keyboard accessible; make it
focusable and respond to keyboard activation by setting a focus policy (e.g.,
Qt.StrongFocus) and an accessible name/role, and implement/override
keyPressEvent to trigger the same file-picker action as mouse activation (reuse
the existing mousePressEvent or the file-open method used by FileDropZone). Also
ensure the widget exposes a clear accessibleName (e.g., "Local file picker") so
screen readers can announce it and that setProperty("dragOver", False) remains
unchanged.

In `@src/gui/widgets/settings_card.py`:
- Around line 55-58: The tooltip is only applied to the label in the toggle row,
causing inconsistent behavior compared to add_row; update the toggle row code in
settings_card.py so that when tooltip is present you call setToolTip(tooltip) on
the toggle widget as well as on label (i.e., ensure both
label.setToolTip(tooltip) and toggle.setToolTip(tooltip) are invoked for the
toggle row creation function/method).

In `@src/gui/widgets/sidebar.py`:
- Line 59: The lambda connected to btn.clicked in add_section captures the loop
variable index by reference so every button emits the final index; change the
connection to capture the current index as a default argument and accept the
clicked bool (e.g., use a lambda like lambda checked=False, idx=index:
self.section_changed.emit(idx) or use functools.partial to bind idx) so each
button emits its own index when clicked; update the btn.clicked.connect call and
ensure self.section_changed.emit receives the bound idx.

---

Nitpick comments:
In `@src/gui/config.py`:
- Around line 71-76: The try/except around opening and loading _CONFIG_FILE uses
a broad except; change it to catch expected errors only (e.g.,
json.JSONDecodeError, TypeError, OSError) so real bugs surface: in the block
that opens _CONFIG_FILE, catch those specific exceptions and call
logger.warning(..., exc_info=True) as before, and do the same for the later
load/save block that uses config.update and file operations; reference the
symbols _CONFIG_FILE, json.load, config.update, and logger.warning when making
the change.

In `@src/gui/main_window.py`:
- Around line 95-98: MainWindow is directly accessing QueueTab's private _log by
calling self._queue_tab._log.append_line(...); introduce a public method on
QueueTab (e.g., append_error or append_line) that accepts the error text and
handles logging, then replace the call in MainWindow to use
self._queue_tab.append_error("No input source selected. Select a YouTube URL or
local file first.") so MainWindow stops depending on _log and uses the public
API of QueueTab.
- Around line 126-133: The try/except around calling
_settings_tab.collect_config, _preferences_tab.collect_preferences and
save_config is too broad; replace the bare "except Exception" with handling only
expected persistence/parsing errors (e.g., IOError/OSError,
json.JSONDecodeError, ValueError or your app's persistence error) and log those
with logger.warning(..., exc_info=True), but allow other unexpected exceptions
to propagate (or re-raise after logging) so real bugs aren't masked; adjust the
block around collect_config, collect_preferences and save_config accordingly.

In `@src/gui/settings_tab.py`:
- Around line 473-476: The tab mutates SettingsCard's private _layout
(card._layout.removeItem(...)) after calling card.add_row; instead add a public
helper on SettingsCard (e.g., add_labeled_row or add_custom_row) that accepts a
label string and a QWidget/layout so callers like this settings_tab can create
the labeled MuseScore row by calling that helper instead of poking card._layout;
update the tab to call the new SettingsCard method (mirroring the pattern used
in PreferencesTab) and remove the direct access to card._layout and the manual
removeItem call.

In `@src/gui/widgets/animated_button.py`:
- Around line 1-15: The class AnimatedButton and its docstring claim hover
animation but contain no animation logic; update either the name or the
docstring to reflect the actual behavior: either rename the class (e.g.,
StyledButton or PrimaryButton) everywhere it’s used or change the class
docstring to state that styling/hover effects are provided by the app stylesheet
(styles.qss) rather than by internal animation code; adjust references to
AnimatedButton in code and tests if renaming, or update the __init__ docstring
and any comments around setObjectName("primaryButton") and setCursor to make it
clear animations are CSS-driven.

In `@src/gui/widgets/log_viewer.py`:
- Line 5: The import line brings QTimer into scope but it is unused in this
module; remove QTimer from the from PySide6.QtCore import ... statement (leaving
Qt) to clean up the unused import in log_viewer.py (remove the QTimer token from
the import list).

In `@src/gui/widgets/sidebar.py`:
- Line 69: Replace the hardcoded version string used to construct version_label
in sidebar.py with a single-source lookup — call
importlib.metadata.version("UltraSinger") (or importlib_metadata.version for
older Python) to fetch the package version and use that value when creating the
QLabel (e.g., version_label = QLabel(f"v{version}")). Add a small try/except
fallback to a sensible default like "v0.0.0" if the import or lookup fails so
the GUI still loads. Ensure you update the symbol version_label and any nearby
code that formats or displays the version to use the fetched value instead of
the literal string.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ac4625da-1cb3-4028-b4ae-234938d784d5

📥 Commits

Reviewing files that changed from the base of the PR and between 2c50076 and 3d51f2f.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (20)
  • docker-compose.yml
  • pyproject.toml
  • src/gui/__init__.py
  • src/gui/browser_tab.py
  • src/gui/config.py
  • src/gui/cookie_manager.py
  • src/gui/main_window.py
  • src/gui/preferences_tab.py
  • src/gui/queue_tab.py
  • src/gui/resources/styles.qss
  • src/gui/settings_tab.py
  • src/gui/ultrasinger_runner.py
  • src/gui/widgets/__init__.py
  • src/gui/widgets/animated_button.py
  • src/gui/widgets/file_drop_zone.py
  • src/gui/widgets/log_viewer.py
  • src/gui/widgets/settings_card.py
  • src/gui/widgets/sidebar.py
  • src/gui/widgets/toggle_switch.py
  • src/gui_main.py

Comment thread docker-compose.yml Outdated
Comment thread src/gui/browser_tab.py
Comment thread src/gui/config.py Outdated
Comment thread src/gui/main_window.py Outdated
Comment thread src/gui/preferences_tab.py
Comment thread src/gui/ultrasinger_runner.py
Comment thread src/gui/ultrasinger_runner.py Outdated
Comment thread src/gui/widgets/file_drop_zone.py
Comment thread src/gui/widgets/settings_card.py
Comment thread src/gui/widgets/sidebar.py Outdated
Actionable fixes:
- docker-compose.yml: Use absolute paths in usage examples to match WORKDIR
- browser_tab.py: Call _update_cookie_status() before loading URL
- config.py: Atomic write + chmod 0o600 for API key security; narrow
  exception handling to json.JSONDecodeError, TypeError, OSError
- main_window.py: Wrap cookie export calls in try/except OSError;
  narrow closeEvent except to specific exceptions
- preferences_tab.py: _save() now persists cookie_file via collect_preferences()
- settings_tab.py: Add QDoubleValidator for BPM, QIntValidator for octave
- ultrasinger_runner.py: Redact --llm_api_key from logged command;
  check _cancelled before spawning subprocess
- file_drop_zone.py: Add keyboard accessibility (focus policy + keyPressEvent)
- settings_card.py: Apply tooltip to toggle widget in add_toggle_row
- sidebar.py: Fix lambda loop variable capture with default argument

Nitpick fixes:
- animated_button.py: Fix module/class docstrings to match actual behavior
- log_viewer.py: Remove unused QTimer import
- sidebar.py: Use importlib.metadata.version() instead of hardcoded string
- settings_card.py: Add remove_last_item() public method; replace _layout
  direct access in settings_tab.py and preferences_tab.py
- queue_tab.py: Add append_log() public method; use it from main_window.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@MrDix

MrDix commented Mar 18, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 18, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (3)
src/gui/ultrasinger_runner.py (1)

97-105: ⚠️ Potential issue | 🟠 Major

cancel() still blocks the UI path for up to 5 seconds.

QueueTab._on_cancel() triggers UltraSingerRunner.cancel(), which directly calls worker cancel(); Line 103 then blocks with wait(timeout=5). This can freeze UI responsiveness during cancel.

🛠️ Non-blocking cancellation pattern
 import logging
 import shutil
 import subprocess
 import sys
+import threading
 from pathlib import Path
@@
     def cancel(self):
         """Terminate the subprocess."""
         self._cancelled = True
         if self._process and self._process.poll() is None:
             self._process.terminate()
-            try:
-                self._process.wait(timeout=5)
-            except subprocess.TimeoutExpired:
-                self._process.kill()
+            threading.Thread(target=self._wait_and_kill_if_needed, daemon=True).start()
+
+    def _wait_and_kill_if_needed(self):
+        process = self._process
+        if not process:
+            return
+        try:
+            process.wait(timeout=5)
+        except subprocess.TimeoutExpired:
+            if process.poll() is None:
+                process.kill()
#!/bin/bash
# Verify the synchronous blocking cancel path from UI -> runner -> worker.
rg -n -C3 'def _on_cancel|def cancel|self\._worker\.cancel\(|wait\(timeout=5\)' src/gui/queue_tab.py src/gui/ultrasinger_runner.py

Also applies to: 159-163

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/ultrasinger_runner.py` around lines 97 - 105, The cancel()
implementation in UltraSingerRunner blocks the UI by calling
self._process.wait(timeout=5) synchronously (triggered from
QueueTab._on_cancel()); change cancel() to perform the terminate/wait/kill
sequence off the UI thread—e.g., start a short-lived background thread or submit
a task to the existing worker thread that calls self._process.terminate(), then
waits up to 5s and calls kill() on TimeoutExpired—so cancel() returns
immediately; apply the same non-blocking pattern for the duplicate block around
the other wait usage (the second cancel-like sequence at lines ~159-163) and
reference the _process, cancel(), and QueueTab._on_cancel symbols when updating
the code.
src/gui/widgets/file_drop_zone.py (1)

25-26: ⚠️ Potential issue | 🟡 Minor

Finalize keyboard/a11y behavior by naming the control and accepting handled key events.

This widget is focusable, but without an accessible name it may be announced generically. Also, handled Enter/Space should explicitly accept/return.

♿ Suggested follow-up
         self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
+        self.setAccessibleName("Local file picker")
         self.setProperty("dragOver", False)
@@
     def keyPressEvent(self, event):
         """Open file dialog on Enter or Space key press."""
         if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Space):
             self._open_file_dialog()
+            event.accept()
+            return
         else:
             super().keyPressEvent(event)

Also applies to: 72-77

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/widgets/file_drop_zone.py` around lines 25 - 26, The widget is
focusable but lacks an accessible name and does not mark handled key events as
accepted; in the FileDropZone constructor call self.setAccessibleName("File drop
zone") (or another clear label) alongside the existing self.setFocusPolicy and
self.setProperty, and implement/modify keyPressEvent(self, event) in the
FileDropZone class to check for Enter/Return/Space (Qt.Key_Return, Qt.Key_Enter,
Qt.Key_Space), call the existing activation/submit handler (or perform the same
action as mouse drop), then call event.accept() and return (do not fall through
to super for handled keys); apply the same setAccessibleName and keyPressEvent
acceptance changes to the other focusable instance(s) referenced around the
72-77 block.
src/gui/widgets/settings_card.py (1)

69-73: ⚠️ Potential issue | 🟡 Minor

Incomplete cleanup when removing layout items.

removeItem() only removes the item from the layout's management but does not delete or hide any associated widget. The widget will remain visible and parented, potentially causing visual artifacts or memory accumulation if remove_last_item is called repeatedly.

Proposed fix to properly clean up widgets and layouts
     def remove_last_item(self):
         """Remove the last item from the card layout."""
-        last = self._layout.itemAt(self._layout.count() - 1)
-        if last:
-            self._layout.removeItem(last)
+        if self._layout.count() == 0:
+            return
+        last = self._layout.takeAt(self._layout.count() - 1)
+        if last.widget():
+            last.widget().deleteLater()
+        elif last.layout():
+            # Recursively delete nested layout contents
+            while last.layout().count():
+                child = last.layout().takeAt(0)
+                if child.widget():
+                    child.widget().deleteLater()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/widgets/settings_card.py` around lines 69 - 73, remove_last_item
currently calls self._layout.removeItem(last) which only detaches the
QLayoutItem but leaves any associated widget/layout visible and parented; change
the logic to remove and fully clean up the item: retrieve the QLayoutItem (use
self._layout.takeAt(self._layout.count()-1) or the existing last), then if
last.widget() exists call widget.hide(); widget.setParent(None);
widget.deleteLater(); if last.layout() exists recursively clear its children and
call deleteLater() on child widgets/layouts; finally ensure the removed
QLayoutItem is deleted or allowed to be GC'd. Use the symbols remove_last_item,
self._layout, last, last.widget(), last.layout(), and widget.deleteLater() to
locate and implement the fix.
🧹 Nitpick comments (3)
src/gui/main_window.py (1)

29-29: Avoid hard-coded app version in the window title.

Using a fixed version string here will drift from packaged runtime version.

♻️ Suggested refactor
+import importlib.metadata
@@
-        self.setWindowTitle("UltraSinger v0.0.13.dev16")
+        try:
+            version = importlib.metadata.version("ultrasinger")
+        except importlib.metadata.PackageNotFoundError:
+            version = "dev"
+        self.setWindowTitle(f"UltraSinger v{version}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/main_window.py` at line 29, The window title currently hard-codes the
app version in the call to self.setWindowTitle("UltraSinger v0.0.13.dev16");
change it to construct the title at runtime using the application's actual
version (for example by reading QCoreApplication.applicationVersion() or an
exported __version__/VERSION constant from the package metadata) and pass that
formatted string to self.setWindowTitle; update the MainWindow initializer (or
wherever self.setWindowTitle is called) to import or query the canonical version
source and format "UltraSinger v{version}" instead of the fixed literal.
src/gui/browser_tab.py (2)

111-121: Consider adding accessible names for navigation buttons.

The navigation buttons use Unicode symbols (, , ) without accessible names. Screen reader users won't know what these buttons do. Adding setAccessibleName() would improve accessibility.

Proposed fix to add accessible names
         self._back_btn = QPushButton("\u25C0")
+        self._back_btn.setAccessibleName("Back")
         self._back_btn.clicked.connect(self._view.back)
         tb_layout.addWidget(self._back_btn)

         self._fwd_btn = QPushButton("\u25B6")
+        self._fwd_btn.setAccessibleName("Forward")
         self._fwd_btn.clicked.connect(self._view.forward)
         tb_layout.addWidget(self._fwd_btn)

         self._refresh_btn = QPushButton("\u21BB")
+        self._refresh_btn.setAccessibleName("Refresh")
         self._refresh_btn.clicked.connect(self._view.reload)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/browser_tab.py` around lines 111 - 121, Add accessible names to the
navigation QPushButton instances so screen readers can announce their purpose:
call setAccessibleName(...) on the _back_btn, _fwd_btn, and _refresh_btn (e.g.,
"Back", "Forward", "Refresh") after each button is created in the BrowserTab GUI
code where _back_btn, _fwd_btn, and _refresh_btn are instantiated and before
adding them to tb_layout; ensure the accessible names are clear and match the
connected actions (_view.back, _view.forward, _view.reload).

75-83: Hardcoded user agent version will become outdated.

The Chrome version 131.0.0.0 in the user agent string will age over time. Some websites may eventually flag older user agents. Consider either:

  • Accepting this as a known limitation
  • Deriving the version from QWebEngineProfile.defaultUserAgent() and patching it
  • Making it configurable

This is a minor maintenance concern and acceptable for an initial implementation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/browser_tab.py` around lines 75 - 83, The hardcoded Chrome version in
the user agent set via self._profile.setHttpUserAgent is brittle; update the
code to generate the UA dynamically instead of the literal "Chrome/131.0.0.0".
For example, retrieve the current default agent from
QWebEngineProfile.defaultProfile().httpUserAgent() or
QWebEngineProfile.defaultUserAgent(), parse/extract its Chrome/major.minor
string and splice that into the custom UA built around
get_browser_profile_path()/QWebEngineProfile, or make the UA string configurable
(e.g., via a config value or constructor parameter) so setHttpUserAgent uses the
derived or configured value rather than a hardcoded version.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/gui/config.py`:
- Around line 75-79: In load_config(), currently config.update(stored) assumes
the JSON payload is a dict; first validate that the loaded value (stored) is a
mapping before updating: after json.load(f) check isinstance(stored, dict) (or
collections.abc.Mapping) and only call config.update(stored) when true,
otherwise log a warning that the config file has unexpected JSON shape and skip
applying it (or replace with an empty dict) so startup recovery path is used;
reference the load_config() function and the stored/config variables to locate
where to add this validation and logging.

In `@src/gui/preferences_tab.py`:
- Around line 180-186: In _export_cookies, guard calls to
self._cookie_manager.export_netscape by first using the current UI path value
(read from self._cookie_path.text() if non-empty, falling back to
self._config.get("cookie_file", "")), wrap the export_netscape(...) call in a
try/except that catches OSError/IOError/Exception, log or display a non-fatal
error to the user (e.g. via a QMessageBox) on failure, and only call
self._cookie_path.setText(...) when export succeeds; ensure _cookie_manager is
still checked before attempting export to avoid breaking the Preferences flow.

In `@src/gui/queue_tab.py`:
- Around line 116-119: At conversion start reset the label styles so previous
colored styles don't persist: in the block where _status_icon, _status_label,
and _stage_label are updated (the conversion-start sequence using _status_icon,
_status_label, and _stage_label), call setStyleSheet("") on _status_label (and
_stage_label if needed) to clear any prior color/formatting before setting the
new text and icon.

In `@src/gui/ultrasinger_runner.py`:
- Around line 172-275: Missing mapping for the calculate_score GUI setting:
update build_args in ultrasinger_runner.py to append "--calculate_score" when
config.get("calculate_score") is truthy (add this check after the LLM correction
block inside build_args), and then add the corresponding long option and parser
handling in UltraSinger.py by adding "calculate_score" to the long-options list
and handling it in the argument parsing switch/case so the CLI recognizes and
sets the calculate_score behavior.

In `@src/gui/widgets/animated_button.py`:
- Around line 13-14: The primary flag currently sets the Qt object name via
setObjectName("primaryButton") in AnimatedButton (primary) but the app
stylesheet (styles.qss) has no corresponding `#primaryButton` rule, so change the
stylesheet or the widget naming: add a `#primaryButton` selector block to
src/gui/resources/styles.qss with the visual rules you want for primary buttons
(colors, border, hover, pressed states) so the setObjectName("primaryButton")
call takes effect, or alternatively change the AnimatedButton code to set an
object name that matches an existing rule in styles.qss; ensure the object name
string used in setObjectName(...) and the selector in styles.qss are identical.

---

Duplicate comments:
In `@src/gui/ultrasinger_runner.py`:
- Around line 97-105: The cancel() implementation in UltraSingerRunner blocks
the UI by calling self._process.wait(timeout=5) synchronously (triggered from
QueueTab._on_cancel()); change cancel() to perform the terminate/wait/kill
sequence off the UI thread—e.g., start a short-lived background thread or submit
a task to the existing worker thread that calls self._process.terminate(), then
waits up to 5s and calls kill() on TimeoutExpired—so cancel() returns
immediately; apply the same non-blocking pattern for the duplicate block around
the other wait usage (the second cancel-like sequence at lines ~159-163) and
reference the _process, cancel(), and QueueTab._on_cancel symbols when updating
the code.

In `@src/gui/widgets/file_drop_zone.py`:
- Around line 25-26: The widget is focusable but lacks an accessible name and
does not mark handled key events as accepted; in the FileDropZone constructor
call self.setAccessibleName("File drop zone") (or another clear label) alongside
the existing self.setFocusPolicy and self.setProperty, and implement/modify
keyPressEvent(self, event) in the FileDropZone class to check for
Enter/Return/Space (Qt.Key_Return, Qt.Key_Enter, Qt.Key_Space), call the
existing activation/submit handler (or perform the same action as mouse drop),
then call event.accept() and return (do not fall through to super for handled
keys); apply the same setAccessibleName and keyPressEvent acceptance changes to
the other focusable instance(s) referenced around the 72-77 block.

In `@src/gui/widgets/settings_card.py`:
- Around line 69-73: remove_last_item currently calls
self._layout.removeItem(last) which only detaches the QLayoutItem but leaves any
associated widget/layout visible and parented; change the logic to remove and
fully clean up the item: retrieve the QLayoutItem (use
self._layout.takeAt(self._layout.count()-1) or the existing last), then if
last.widget() exists call widget.hide(); widget.setParent(None);
widget.deleteLater(); if last.layout() exists recursively clear its children and
call deleteLater() on child widgets/layouts; finally ensure the removed
QLayoutItem is deleted or allowed to be GC'd. Use the symbols remove_last_item,
self._layout, last, last.widget(), last.layout(), and widget.deleteLater() to
locate and implement the fix.

---

Nitpick comments:
In `@src/gui/browser_tab.py`:
- Around line 111-121: Add accessible names to the navigation QPushButton
instances so screen readers can announce their purpose: call
setAccessibleName(...) on the _back_btn, _fwd_btn, and _refresh_btn (e.g.,
"Back", "Forward", "Refresh") after each button is created in the BrowserTab GUI
code where _back_btn, _fwd_btn, and _refresh_btn are instantiated and before
adding them to tb_layout; ensure the accessible names are clear and match the
connected actions (_view.back, _view.forward, _view.reload).
- Around line 75-83: The hardcoded Chrome version in the user agent set via
self._profile.setHttpUserAgent is brittle; update the code to generate the UA
dynamically instead of the literal "Chrome/131.0.0.0". For example, retrieve the
current default agent from QWebEngineProfile.defaultProfile().httpUserAgent() or
QWebEngineProfile.defaultUserAgent(), parse/extract its Chrome/major.minor
string and splice that into the custom UA built around
get_browser_profile_path()/QWebEngineProfile, or make the UA string configurable
(e.g., via a config value or constructor parameter) so setHttpUserAgent uses the
derived or configured value rather than a hardcoded version.

In `@src/gui/main_window.py`:
- Line 29: The window title currently hard-codes the app version in the call to
self.setWindowTitle("UltraSinger v0.0.13.dev16"); change it to construct the
title at runtime using the application's actual version (for example by reading
QCoreApplication.applicationVersion() or an exported __version__/VERSION
constant from the package metadata) and pass that formatted string to
self.setWindowTitle; update the MainWindow initializer (or wherever
self.setWindowTitle is called) to import or query the canonical version source
and format "UltraSinger v{version}" instead of the fixed literal.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 49ac81c0-3ae3-4e1c-b612-9aae754e9fc9

📥 Commits

Reviewing files that changed from the base of the PR and between 3d51f2f and bcce9cb.

📒 Files selected for processing (13)
  • docker-compose.yml
  • src/gui/browser_tab.py
  • src/gui/config.py
  • src/gui/main_window.py
  • src/gui/preferences_tab.py
  • src/gui/queue_tab.py
  • src/gui/settings_tab.py
  • src/gui/ultrasinger_runner.py
  • src/gui/widgets/animated_button.py
  • src/gui/widgets/file_drop_zone.py
  • src/gui/widgets/log_viewer.py
  • src/gui/widgets/settings_card.py
  • src/gui/widgets/sidebar.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • docker-compose.yml
  • src/gui/widgets/log_viewer.py

Comment thread src/gui/config.py
Comment thread src/gui/preferences_tab.py
Comment thread src/gui/queue_tab.py
Comment on lines +172 to +275
def build_args(self, config: dict, input_source: str) -> list[str]:
"""Build CLI argument list from configuration dictionary."""
args = ["-i", input_source]

if config.get("output_folder"):
args.extend(["-o", config["output_folder"]])

# Cookie file
if config.get("cookie_file") and Path(config["cookie_file"]).exists():
args.extend(["--cookiefile", config["cookie_file"]])

# Whisper settings
if config.get("whisper_model"):
args.extend(["--whisper", config["whisper_model"]])
if config.get("whisper_batch_size"):
args.extend(["--whisper_batch_size", str(config["whisper_batch_size"])])
if config.get("whisper_compute_type"):
args.extend(["--whisper_compute_type", config["whisper_compute_type"]])
if config.get("whisper_align_model"):
args.extend(["--whisper_align_model", config["whisper_align_model"]])

# Language
if config.get("language_mode") == "manual" and config.get("language"):
args.extend(["--language", config["language"]])

# Format version
if config.get("format_version"):
args.extend(["--format_version", config["format_version"]])

# Demucs
if config.get("demucs_model"):
args.extend(["--demucs", config["demucs_model"]])

# BPM override
bpm = config.get("bpm_override", "")
if bpm:
args.extend(["--bpm", str(bpm)])

# Octave shift
octave = config.get("octave_shift", "")
if octave:
args.extend(["--octave", str(octave)])

# Denoise
if config.get("denoise_nr") is not None:
args.extend(["--denoise_nr", str(config["denoise_nr"])])
if config.get("denoise_nf") is not None:
args.extend(["--denoise_nf", str(config["denoise_nf"])])

# Boolean flags (disabled by default in CLI)
if config.get("disable_separation"):
args.append("--disable_separation")
if config.get("disable_quantization"):
args.append("--disable_quantization")
if config.get("disable_vocal_center"):
args.append("--disable_vocal_center")
if config.get("disable_onset_correction"):
args.append("--disable_onset_correction")
if config.get("disable_denoise_track_noise"):
args.append("--disable_denoise_track_noise")
if not config.get("hyphenation", True):
args.append("--disable_hyphenation")
if not config.get("create_karaoke", True):
args.append("--disable_karaoke")

# Boolean flags (enabled by CLI flag)
if config.get("keep_cache"):
args.append("--keep_cache")
if config.get("create_plot"):
args.append("--plot")
if config.get("create_midi"):
args.append("--midi")
if config.get("create_audio_chunks"):
args.append("--create_audio_chunks")
if config.get("keep_numbers"):
args.append("--keep_numbers")
if config.get("force_cpu"):
args.append("--force_cpu")
if config.get("force_whisper_cpu"):
args.append("--force_whisper_cpu")

# Experimental features
if config.get("syllable_split"):
args.append("--syllable_split")
if config.get("vocal_gap_fill"):
args.append("--vocal_gap_fill")

# LLM correction
if config.get("llm_correct"):
args.append("--llm_correct")
if config.get("llm_api_base_url"):
args.extend(["--llm_api_base_url", config["llm_api_base_url"]])
if config.get("llm_api_key"):
args.extend(["--llm_api_key", config["llm_api_key"]])
if config.get("llm_model"):
args.extend(["--llm_model", config["llm_model"]])

# Paths
if config.get("musescore_path"):
args.extend(["--musescore_path", config["musescore_path"]])
if config.get("ffmpeg_path"):
args.extend(["--ffmpeg", config["ffmpeg_path"]])

return args

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the key exists in config/settings but is missing from build_args mappings.
rg -n -C2 '"calculate_score"' src/gui/config.py src/gui/settings_tab.py src/gui/ultrasinger_runner.py

# Identify the actual score-related CLI argument in the main CLI parser.
rg -n -C2 'add_argument\(|score' src/UltraSinger.py

Repository: MrDix/UltraSinger

Length of output: 3037


🏁 Script executed:

#!/bin/bash
# Search for the getopt options definition and look for calculate_score in argument parsing
rg -n "getopt\(" src/UltraSinger.py -A 30 | head -50

# Search for all CLI option definitions in UltraSinger.py
rg -n "calculate_score" src/UltraSinger.py

# Look for the long_options string in argument parsing
rg -n "long_options\s*=" src/UltraSinger.py -A 50 | head -80

Repository: MrDix/UltraSinger

Length of output: 1626


🏁 Script executed:

#!/bin/bash
# Find where short and long variables are defined in the argument parsing section
rg -n "^\s*(short|long)\s*=" src/UltraSinger.py

# Also search for long_options definition
rg -n "long_options|short_options" src/UltraSinger.py

Repository: MrDix/UltraSinger

Length of output: 107


🏁 Script executed:

#!/bin/bash
# Read the long options definition
sed -n '1061,1150p' src/UltraSinger.py

Repository: MrDix/UltraSinger

Length of output: 1129


🏁 Script executed:

#!/bin/bash
# Find where build_args is called/used
rg -n "build_args" src/gui/ultrasinger_runner.py -B 3 -A 3

Repository: MrDix/UltraSinger

Length of output: 331


🏁 Script executed:

#!/bin/bash
# Find where build_args is called
rg -n "self\.build_args\|\.build_args" src/gui/ultrasinger_runner.py

Repository: MrDix/UltraSinger

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Read the ultrasinger_runner.py file to find how build_args is used
cat -n src/gui/ultrasinger_runner.py | head -300

Repository: MrDix/UltraSinger

Length of output: 11577


🏁 Script executed:

#!/bin/bash
# Search for where build_args or runner.start is called in GUI files
rg -n "build_args|runner\.start" src/gui/ -B 2 -A 2

Repository: MrDix/UltraSinger

Length of output: 980


Add missing --calculate_score flag to CLI argument mapping.

The calculate_score setting is collected in the GUI settings but never mapped to a CLI argument in build_args(). This means the toggle is ineffective when running conversions—the flag will always use its default value (False) regardless of the user's selection.

Add the mapping to build_args():

Suggested fix
# After the LLM correction block, add:
if config.get("calculate_score"):
    args.append("--calculate_score")

Also add "calculate_score" to the long options list in UltraSinger.py (around line 1062) and handle it in the argument parsing switch statement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/ultrasinger_runner.py` around lines 172 - 275, Missing mapping for
the calculate_score GUI setting: update build_args in ultrasinger_runner.py to
append "--calculate_score" when config.get("calculate_score") is truthy (add
this check after the LLM correction block inside build_args), and then add the
corresponding long option and parser handling in UltraSinger.py by adding
"calculate_score" to the long-options list and handling it in the argument
parsing switch/case so the CLI recognizes and sets the calculate_score behavior.

Comment on lines +13 to +14
if primary:
self.setObjectName("primaryButton")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

primary=True has no visual effect right now.

Line 14 sets primaryButton, but src/gui/resources/styles.qss (Lines 1-250) has no #primaryButton rule, so primary/non-primary buttons render the same.

🎨 Suggested wiring in stylesheet
+/* Primary CTA button */
+QPushButton#primaryButton {
+    background-color: `#e91e63`;
+    color: `#ffffff`;
+    border: 1px solid rgba(255,255,255,0.08);
+    border-radius: 8px;
+    padding: 8px 14px;
+    font-weight: 600;
+}
+
+QPushButton#primaryButton:hover {
+    background-color: `#f06292`;
+}
+
+QPushButton#primaryButton:pressed {
+    background-color: `#c2185b`;
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/widgets/animated_button.py` around lines 13 - 14, The primary flag
currently sets the Qt object name via setObjectName("primaryButton") in
AnimatedButton (primary) but the app stylesheet (styles.qss) has no
corresponding `#primaryButton` rule, so change the stylesheet or the widget
naming: add a `#primaryButton` selector block to src/gui/resources/styles.qss with
the visual rules you want for primary buttons (colors, border, hover, pressed
states) so the setObjectName("primaryButton") call takes effect, or
alternatively change the AnimatedButton code to set an object name that matches
an existing rule in styles.qss; ensure the object name string used in
setObjectName(...) and the selector in styles.qss are identical.

- ultrasinger_runner: non-blocking cancel via daemon thread
- file_drop_zone: accessible name + event.accept() on keypress
- settings_card: proper widget cleanup in remove_last_item
- config: validate JSON is dict before merging
- preferences_tab: guard export_netscape with try/except
- queue_tab: reset status label style at conversion start
- main_window: dynamic version from importlib.metadata
- browser_tab: accessible names for nav buttons, use default UA

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (3)
src/gui/ultrasinger_runner.py (2)

183-286: ⚠️ Potential issue | 🟠 Major

calculate_score is still dead config.

build_args() drops the calculate_score key entirely, and src/UltraSinger.py also has no matching CLI flag in the provided option list. Any GUI or persisted config value for this setting is silently ignored.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/ultrasinger_runner.py` around lines 183 - 286, build_args currently
ignores the calculate_score setting so any GUI/persisted value is dropped; add
handling in build_args to include the corresponding CLI flag (e.g. check
config.get("calculate_score") and append "--calculate_score" or
"--no-calculate_score" as your CLI expects) and also add the matching option
parsing/flag in the CLI entry (update UltraSinger.py to declare/consume the
--calculate_score flag in the option list/argparse setup) so the GUI config and
CLI are consistent; update references to build_args and UltraSinger.py option
list/argparse to ensure the flag name matches exactly.

48-66: ⚠️ Potential issue | 🟠 Major

Cancellation is still racy around process startup and exit.

cancel() can flip _cancelled before _process is assigned, but run() only checks the flag before building cmd. In that window the subprocess still starts, and the final if self._cancelled then rewrites even a normal exit to -2. Downstream, QueueTab can show “Cancelled” after the conversion actually ran and produced output. Re-check after Popen() or synchronize _cancelled and _process so only a live process that was actually terminated is reported as cancelled.

Also applies to: 92-96, 98-117

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/ultrasinger_runner.py` around lines 48 - 66, Rework run() and
cancel() synchronization so cancellation is only reported when a live subprocess
was actually terminated: assign self._process immediately from subprocess.Popen
in run(), then re-check self._cancelled right after Popen; if True,
terminate/kill that self._process, mark that you terminated it (e.g.
_terminated_by_cancel flag) and emit finished(-2) and return. Also update the
final exit handling in run() (the block that currently inspects exit codes
around _process.wait()/return) to only emit "Cancelled" / finished(-2) when the
process was actually terminated by your cancel path (check the
_terminated_by_cancel flag or equivalent), and ensure cancel() sets _cancelled
and, if self._process is already set, terminates it immediately. Apply the same
pattern to the other similar blocks referenced (around lines 92-96 and 98-117)
so cancellation is deterministic.
src/gui/preferences_tab.py (1)

3-3: ⚠️ Potential issue | 🟠 Major

Add missing logging import before using it in the error handler.

Line 189 calls logging.getLogger(__name__).error(...) in the except OSError block, but the module never imports logging. A cookie export failure will raise NameError instead of being logged.

🛠️ Minimal fix
+import logging
 from pathlib import Path

Then replace the inline call on line 189 with:

-                    logging.getLogger(__name__).error(
+                    logger.error(

And add after imports:

+logger = logging.getLogger(__name__)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/preferences_tab.py` at line 3, Add "import logging" to the module
imports, then update the exception handler that currently calls
logging.getLogger(__name__).error(...) inline (in the export/cookie failure
block) to use the imported logging: call logging.getLogger(__name__).error with
the same message and include exc_info=True to ensure the caught OSError details
are logged; this affects the exception block in the function handling cookie
export in preferences_tab.py.
🧹 Nitpick comments (1)
src/gui/widgets/file_drop_zone.py (1)

101-103: Consider deduplicating drag-over styling refresh.

The unpolish/polish block is repeated in three places. A small helper (e.g., _set_drag_over(active: bool)) will reduce drift and simplify future changes.

Also applies to: 108-110, 113-115

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/widgets/file_drop_zone.py` around lines 101 - 103, The drag-over
styling refresh is duplicated where you call self.setProperty("dragOver", ...)
followed by self.style().unpolish(self) and self.style().polish(self); add a
small helper method (e.g., _set_drag_over(self, active: bool)) in
file_drop_zone.py that sets self.setProperty("dragOver", active) and performs
self.style().unpolish(self); self.style().polish(self), then replace the three
duplicate blocks (the occurrences using self.setProperty("dragOver",
True/False") and unpolish/polish) with calls to _set_drag_over(True) or
_set_drag_over(False) from the existing event handlers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/gui/preferences_tab.py`:
- Around line 87-89: The code currently calls llm_card.add_row("API Key",
QWidget()); llm_card.remove_last_item(); llm_card.add_layout(key_row) which
drops the "API Key" label and leaves the custom key controls unlabeled; fix by
keeping the label and attaching the key_row directly instead of removing it —
e.g., replace that sequence with a single call that adds the labeled layout (use
llm_card.add_row("API Key", key_row) or the equivalent API to add the key_row as
the widget for the "API Key" label) so the API key controls remain labeled and
accessible (target symbols: llm_card, add_row, remove_last_item, add_layout,
key_row).

In `@src/gui/queue_tab.py`:
- Around line 114-125: In start_conversion(), reset the elapsed display before
starting timers so the previous run's duration doesn't flash; specifically
update the _elapsed_label (e.g. set it to "0s" or an empty duration string) just
prior to calling self._elapsed_timer.start() and self._tick_timer.start(),
ensuring you reference the existing members _elapsed_label, _elapsed_timer, and
_tick_timer in the start_conversion() method.

In `@src/gui/widgets/file_drop_zone.py`:
- Around line 56-69: The widget currently allows unsupported file types because
_open_file_dialog(), drag/drop handlers, and set_file() emit file paths without
a centralized extension check; add a single helper (e.g.,
_validate_and_set_file(path: str) or _is_supported_extension(path: str)) that
checks the file extension against the widget's supported list and only calls
set_file(path) and file_selected.emit(path) when valid; update
_open_file_dialog(), the drop handler, and any callers that currently call
set_file() or file_selected.emit() directly to use this helper so unsupported
files are rejected before being set or emitted (refer to set_file,
_open_file_dialog, and file_selected.emit in the diff).
- Around line 70-71: mousePressEvent currently opens the file picker for any
mouse button; update the mousePressEvent handler to only call
_open_file_dialog() when the event button is the left button (e.g.,
event.button() == Qt.LeftButton) and otherwise delegate to the base
implementation via super().mousePressEvent(_event) so right/middle clicks behave
normally; ensure to import/qualify Qt if needed and reference the existing
mousePressEvent and _open_file_dialog symbols.

---

Duplicate comments:
In `@src/gui/preferences_tab.py`:
- Line 3: Add "import logging" to the module imports, then update the exception
handler that currently calls logging.getLogger(__name__).error(...) inline (in
the export/cookie failure block) to use the imported logging: call
logging.getLogger(__name__).error with the same message and include
exc_info=True to ensure the caught OSError details are logged; this affects the
exception block in the function handling cookie export in preferences_tab.py.

In `@src/gui/ultrasinger_runner.py`:
- Around line 183-286: build_args currently ignores the calculate_score setting
so any GUI/persisted value is dropped; add handling in build_args to include the
corresponding CLI flag (e.g. check config.get("calculate_score") and append
"--calculate_score" or "--no-calculate_score" as your CLI expects) and also add
the matching option parsing/flag in the CLI entry (update UltraSinger.py to
declare/consume the --calculate_score flag in the option list/argparse setup) so
the GUI config and CLI are consistent; update references to build_args and
UltraSinger.py option list/argparse to ensure the flag name matches exactly.
- Around line 48-66: Rework run() and cancel() synchronization so cancellation
is only reported when a live subprocess was actually terminated: assign
self._process immediately from subprocess.Popen in run(), then re-check
self._cancelled right after Popen; if True, terminate/kill that self._process,
mark that you terminated it (e.g. _terminated_by_cancel flag) and emit
finished(-2) and return. Also update the final exit handling in run() (the block
that currently inspects exit codes around _process.wait()/return) to only emit
"Cancelled" / finished(-2) when the process was actually terminated by your
cancel path (check the _terminated_by_cancel flag or equivalent), and ensure
cancel() sets _cancelled and, if self._process is already set, terminates it
immediately. Apply the same pattern to the other similar blocks referenced
(around lines 92-96 and 98-117) so cancellation is deterministic.

---

Nitpick comments:
In `@src/gui/widgets/file_drop_zone.py`:
- Around line 101-103: The drag-over styling refresh is duplicated where you
call self.setProperty("dragOver", ...) followed by self.style().unpolish(self)
and self.style().polish(self); add a small helper method (e.g.,
_set_drag_over(self, active: bool)) in file_drop_zone.py that sets
self.setProperty("dragOver", active) and performs self.style().unpolish(self);
self.style().polish(self), then replace the three duplicate blocks (the
occurrences using self.setProperty("dragOver", True/False") and unpolish/polish)
with calls to _set_drag_over(True) or _set_drag_over(False) from the existing
event handlers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0135d4dd-a4dc-48f4-8d5a-e89d655f658f

📥 Commits

Reviewing files that changed from the base of the PR and between bcce9cb and 230ad6b.

📒 Files selected for processing (8)
  • src/gui/browser_tab.py
  • src/gui/config.py
  • src/gui/main_window.py
  • src/gui/preferences_tab.py
  • src/gui/queue_tab.py
  • src/gui/ultrasinger_runner.py
  • src/gui/widgets/file_drop_zone.py
  • src/gui/widgets/settings_card.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/gui/config.py
  • src/gui/main_window.py

Comment thread src/gui/preferences_tab.py Outdated
Comment thread src/gui/queue_tab.py
Comment thread src/gui/widgets/file_drop_zone.py
Comment thread src/gui/widgets/file_drop_zone.py Outdated
- ultrasinger_runner: deterministic cancellation with _terminated_by_cancel
  flag, re-check after Popen, thread-safe cancel
- file_drop_zone: centralized _validate_and_set(), _set_drag_over() helper,
  left-click only for mousePressEvent
- preferences_tab: add logging import, proper API Key label in layout
- queue_tab: reset elapsed label at conversion start

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@MrDix

MrDix commented Mar 18, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 18, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/gui/ultrasinger_runner.py (1)

193-296: ⚠️ Potential issue | 🟠 Major

calculate_score is still not propagated from GUI config to CLI args.

build_args() maps many settings, but it never appends --calculate_score. Since settings_tab.collect_config() and config.py both include calculate_score, this toggle remains ineffective at runtime.

🛠️ Suggested fix
         # LLM correction
         if config.get("llm_correct"):
             args.append("--llm_correct")
             if config.get("llm_api_base_url"):
                 args.extend(["--llm_api_base_url", config["llm_api_base_url"]])
             if config.get("llm_api_key"):
                 args.extend(["--llm_api_key", config["llm_api_key"]])
             if config.get("llm_model"):
                 args.extend(["--llm_model", config["llm_model"]])

+        # Scoring
+        if config.get("calculate_score"):
+            args.append("--calculate_score")
+
         # Paths
         if config.get("musescore_path"):
             args.extend(["--musescore_path", config["musescore_path"]])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/ultrasinger_runner.py` around lines 193 - 296, The build_args()
function in ultrasinger_runner.py never adds the --calculate_score CLI flag so
the GUI's calculate_score setting has no effect; update build_args (in the same
function) to check config.get("calculate_score") and, when truthy, append the
"--calculate_score" flag to args (similar to other boolean flags like
"keep_cache" and "create_plot") so the CLI receives the toggle.
🧹 Nitpick comments (1)
src/gui/widgets/file_drop_zone.py (1)

56-76: Keep extension validation invariant inside the public setter.

set_file() is public and currently bypasses extension checks; only _validate_and_set() enforces them. If any caller uses set_file() directly, unsupported files can still enter widget state. Consider validating in set_file() and keeping _validate_and_set() as the emit wrapper.

Proposed refactor
-    def set_file(self, path: str):
+    def set_file(self, path: str) -> bool:
         """Set the file path programmatically."""
+        if path:
+            ext = Path(path).suffix.lower()
+            if ext not in ALL_EXTENSIONS:
+                return False
         self._current_file = path
         if path:
             name = Path(path).name
             self._file_label.setText(name)
             self._file_label.show()
             self._text_label.setText("Click to change file")
             self._icon_label.setText("\U0001F3B5")
         else:
             self._file_label.hide()
             self._text_label.setText("Drag file here or click to select")
             self._icon_label.setText("\U0001F4C1")
+        return True
 
     def _validate_and_set(self, path: str):
         """Validate extension, set file, and emit signal."""
-        ext = Path(path).suffix.lower()
-        if ext in ALL_EXTENSIONS:
-            self.set_file(path)
+        if self.set_file(path):
             self.file_selected.emit(path)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/widgets/file_drop_zone.py` around lines 56 - 76, set_file currently
mutates widget state without checking extensions, allowing unsupported files;
move the extension check into set_file (use Path(path).suffix.lower() vs
ALL_EXTENSIONS) so it rejects/ignores invalid paths and only updates
_current_file, _file_label, _text_label, and _icon_label for valid ones, then
keep _validate_and_set as a thin wrapper that calls set_file(path) and emits
file_selected(path) only if set_file accepted the path (e.g., returns a bool or
raises on invalid); ensure callers of set_file receive the same validation
behavior as _validate_and_set and preserve symbols: set_file, _validate_and_set,
ALL_EXTENSIONS, file_selected, _current_file, _file_label, _text_label,
_icon_label.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/gui/preferences_tab.py`:
- Around line 114-118: The cookie-action UI should be disabled whenever
self._cookie_manager is missing: update the constructor/init block that
currently checks cookie_manager to also set the cookie action buttons disabled
when cookie_manager is falsy, and only connect signals and call
self._update_cookie_status when a manager exists; modify
self._update_cookie_status to set the enabled/disabled state of the cookie
action buttons (and the self._cookie_status QLabel text) based on
self._cookie_manager being present, and ensure any signal connections use the
same self._cookie_manager instance to avoid silent no-ops; specifically touch
the _cookie_manager variable, the _cookie_status QLabel, the
_update_cookie_status method, and the cookie action button attributes so they
are disabled when no manager and re-enabled when a manager is available.

In `@src/gui/queue_tab.py`:
- Around line 181-186: In _open_output_folder, wrap the subprocess.Popen calls
in a try/except that catches OSError (or Exception) and logs the failure so the
UI callback can't raise unhandled errors; locate the platform branch using the
subprocess.Popen calls in _open_output_folder, execute the appropriate opener
inside the try block, and in except log the exception details (include the
exception object/message) using the module/class logger so the error is recorded
and the UI remains stable.

---

Duplicate comments:
In `@src/gui/ultrasinger_runner.py`:
- Around line 193-296: The build_args() function in ultrasinger_runner.py never
adds the --calculate_score CLI flag so the GUI's calculate_score setting has no
effect; update build_args (in the same function) to check
config.get("calculate_score") and, when truthy, append the "--calculate_score"
flag to args (similar to other boolean flags like "keep_cache" and
"create_plot") so the CLI receives the toggle.

---

Nitpick comments:
In `@src/gui/widgets/file_drop_zone.py`:
- Around line 56-76: set_file currently mutates widget state without checking
extensions, allowing unsupported files; move the extension check into set_file
(use Path(path).suffix.lower() vs ALL_EXTENSIONS) so it rejects/ignores invalid
paths and only updates _current_file, _file_label, _text_label, and _icon_label
for valid ones, then keep _validate_and_set as a thin wrapper that calls
set_file(path) and emits file_selected(path) only if set_file accepted the path
(e.g., returns a bool or raises on invalid); ensure callers of set_file receive
the same validation behavior as _validate_and_set and preserve symbols:
set_file, _validate_and_set, ALL_EXTENSIONS, file_selected, _current_file,
_file_label, _text_label, _icon_label.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 49dce22b-6bd7-4367-8955-5a1d2c7a612e

📥 Commits

Reviewing files that changed from the base of the PR and between 230ad6b and 7c9f3c1.

📒 Files selected for processing (4)
  • src/gui/preferences_tab.py
  • src/gui/queue_tab.py
  • src/gui/ultrasinger_runner.py
  • src/gui/widgets/file_drop_zone.py

Comment thread src/gui/queue_tab.py Outdated
- preferences_tab: disable cookie buttons when cookie_manager unavailable
- queue_tab: wrap _open_output_folder in try/except OSError, add logger

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@MrDix

MrDix commented Mar 18, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 18, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@MrDix

MrDix commented Mar 18, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 18, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/gui/preferences_tab.py (2)

1-6: Logger defined between import groups.

The logger is initialized at line 6, between the logging/pathlib imports and the PySide6 imports. While functional, conventionally all imports are grouped together before module-level statements.

♻️ Suggested reorder
 """Application preferences and configuration tab."""

 import logging
 from pathlib import Path

-logger = logging.getLogger(__name__)
-
 from PySide6.QtCore import Qt
 from PySide6.QtWidgets import
     ...
 )

 from .widgets import AnimatedButton, SettingsCard, ToggleSwitch

+logger = logging.getLogger(__name__)
+

 class PreferencesTab(QWidget):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/preferences_tab.py` around lines 1 - 6, The module-level logger
(logger) is declared between import groups; move the logger initialization so
all imports remain grouped together first and then module-level statements
follow. Specifically, place the line that defines logger =
logging.getLogger(__name__) after all import statements (including the PySide6
imports elsewhere in this file) so imports are contiguous and the logger is
declared as a standalone module-level variable.

190-206: Consider providing user feedback on export success/failure.

The export operation logs errors but provides no visual feedback to the user. For better UX, consider showing a brief status message or toast notification on success/failure rather than silently logging.

💡 Optional UX enhancement
     def _export_cookies(self):
         if self._cookie_manager:
             # Prefer UI path, fall back to config
             cookie_path = self._cookie_path.text() or self._config.get("cookie_file", "")
             if cookie_path:
                 try:
                     exported = self._cookie_manager.export_netscape(cookie_path)
                     self._cookie_path.setText(str(exported))
+                    # Optional: Show success feedback
+                    if hasattr(self, '_cookie_status'):
+                        self._cookie_status.setText(f"✅ Cookies exported to {exported.name}")
                 except OSError as e:
                     logger.error(
                         "Failed to export cookies to %s: %s", cookie_path, e,
                         exc_info=True,
                     )
+                    # Optional: Show error feedback via QMessageBox
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/gui/preferences_tab.py` around lines 190 - 206, The _export_cookies
routine currently logs failures via logger but gives no user-visible feedback;
update _export_cookies so that after calling
self._cookie_manager.export_netscape(cookie_path) it displays a brief success
message to the user (e.g., status bar/toast/QMessageBox) and on exception shows
a clear error message containing the exception text; use the existing symbols
_export_cookies, _cookie_manager, _cookie_path and _config to locate the code
and ensure the error branch that currently logs via logger.error also triggers
the user-facing failure notification with the same exception details.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/gui/preferences_tab.py`:
- Around line 114-118: The bug: _update_cookie_status unconditionally references
self._cookie_status which is only created when cookie_manager is truthy, causing
AttributeError if called with cookie_manager None; fix by either (preferred)
initializing self._cookie_status in the class __init__ (e.g. set
self._cookie_status = QLabel("") and add it to cookie_card unconditionally) so
it always exists, or add a defensive check at the start of _update_cookie_status
(if getattr(self, "_cookie_status", None) is None: return) to avoid accessing a
missing attribute; update references to
cookie_manager.cookies_changed.connect(self._update_cookie_status) to keep
behavior unchanged.

In `@src/gui/queue_tab.py`:
- Line 151: The button enablement currently checks only that
self._current_output_folder is non-empty; change it to enable only when the path
actually exists and is a directory. Update the check in the code that sets
self._open_folder_btn.setEnabled(...) to verify
os.path.isdir(self._current_output_folder) (or
Path(self._current_output_folder).is_dir()), guarding for None/empty string, and
ensure the module imports os or pathlib as needed; this ensures the Open Output
Folder button is enabled only for a valid existing folder.
- Around line 176-190: Replace the platform-specific subprocess logic in
_open_output_folder with PySide6's QDesktopServices.openUrl: import
QDesktopServices and QUrl from PySide6.QtGui (or QtCore/QtGui as appropriate),
check self._current_output_folder exists, build a QUrl using
QUrl.fromLocalFile(self._current_output_folder) and call
QDesktopServices.openUrl(url) to open the folder; remove subprocess and sys
imports and any platform branching so the method consistently uses
QDesktopServices.openUrl with QUrl.fromLocalFile.

---

Nitpick comments:
In `@src/gui/preferences_tab.py`:
- Around line 1-6: The module-level logger (logger) is declared between import
groups; move the logger initialization so all imports remain grouped together
first and then module-level statements follow. Specifically, place the line that
defines logger = logging.getLogger(__name__) after all import statements
(including the PySide6 imports elsewhere in this file) so imports are contiguous
and the logger is declared as a standalone module-level variable.
- Around line 190-206: The _export_cookies routine currently logs failures via
logger but gives no user-visible feedback; update _export_cookies so that after
calling self._cookie_manager.export_netscape(cookie_path) it displays a brief
success message to the user (e.g., status bar/toast/QMessageBox) and on
exception shows a clear error message containing the exception text; use the
existing symbols _export_cookies, _cookie_manager, _cookie_path and _config to
locate the code and ensure the error branch that currently logs via logger.error
also triggers the user-facing failure notification with the same exception
details.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0e4af703-d14a-4975-9d8e-cef28e76d280

📥 Commits

Reviewing files that changed from the base of the PR and between 7c9f3c1 and 77aab93.

📒 Files selected for processing (2)
  • src/gui/preferences_tab.py
  • src/gui/queue_tab.py

Comment thread src/gui/preferences_tab.py
Comment thread src/gui/queue_tab.py Outdated
Comment thread src/gui/queue_tab.py Outdated
- queue_tab: use QDesktopServices.openUrl instead of subprocess for folder
  opening, check Path.exists() for open button enablement
- preferences_tab: guard _update_cookie_status against missing _cookie_status,
  move logger after all imports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@MrDix

MrDix commented Mar 18, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 18, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Anthony and others added 6 commits March 19, 2026 02:03
Split into two services using YAML anchors:
- `ultrasinger` (default): CPU mode, works everywhere
- `ultrasinger-gpu` (profile: gpu): requires NVIDIA Container Toolkit

Usage: `docker compose run ultrasinger ...` for CPU,
`docker compose --profile gpu run ultrasinger-gpu ...` for GPU.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Explains CPU vs GPU mode, volume mounts, and cookie usage.
Keeps existing container/README.md link for manual setups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove root docker-compose.yml (was using profiles approach)
- Replace old compose-nogpu.yml with compose-cpu.yml (build from source)
- Update compose-gpu.yml (build from source, correct volume paths)
- Rewrite docker.md: run vs up -d, volumes, cookies, manual build
- Rewrite podman.md: Bash + PowerShell examples, run vs detached
- Update container/README.md with quick start
- Simplify main README container section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- New "Run (GUI)" section: install, start, feature overview
- Add GUI to table of contents
- Clarify: GUI is native desktop app, not available in containers
- Remove misleading "GUI mode" references from container docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- API keys stored in system keyring (Windows Credential Manager, macOS
  Keychain, Linux Secret Service) instead of config.json
- Environment variable fallback: ULTRASINGER_LLM_API_KEY
- Legacy migration: keys in config.json auto-migrate to keyring on first load
- Directory permissions: ~/.ultrasinger/ restricted to owner-only
  (chmod 0o700 on POSIX, icacls on Windows)
- config.json file permissions: 0o600 on POSIX
- Secret keys stripped from JSON before write
- Preferences tab shows keyring backend status
- 9 new tests covering priority chain, migration, stripping, ACLs
- keyring>=25.0 added to gui extras

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Sidebar now has a file drop zone at top — drop file, convert button
  appears immediately. No need to navigate to Settings first.
- YouTube flow: click Convert in browser → URL appears in sidebar with
  convert button. One click to start.
- Settings tab: removed input section (YouTube/Local File radio, URL
  field, FileDropZone). Now only contains conversion options.
- Input source management moved from SettingsTab to Sidebar.

Before: Settings → Local File → Drop → Start (4 steps)
After:  Drop in sidebar → Start (2 steps)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Anthony and others added 7 commits March 19, 2026 03:35
YouTube is a single-page app — internal navigation (clicking videos,
search results) only fires urlChanged, not loadFinished.  Re-inject the
Convert button overlay after each SPA navigation with a short delay to
let YouTube's rendering settle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace manual runJavaScript() calls with QWebEngineScript (Chromium's
user-script API, equivalent to Tampermonkey).  The script is now
automatically injected on every full page load by the engine itself.

Additional robustness for YouTube's SPA architecture:
- Listen for YouTube's custom `yt-navigate-finish` event
- Periodic 3s poll as fallback if YouTube removes the element
- z-index raised to max (2147483647) to stay above YouTube's UI
- Use addEventListener instead of onX properties
- console.log for debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Browser tab:
- Convert overlay only appears on /watch?v= video pages, hidden elsewhere
- URL bar is now editable — paste YouTube URLs and press Enter to navigate
- URL validation: only youtube.com, youtu.be, music.youtube.com accepted

Settings tab:
- Reset buttons (↺) next to every setting to restore defaults
- Scroll-safe spin boxes: mouse wheel only changes value when focused
  (prevents accidental changes while scrolling the settings page)
- All ComboBoxes also scroll-safe
- Info labels are now text-selectable (copy URLs like console.groq.com)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Critical bug: when clicking Convert on a YouTube video, the URL often
contains &list= parameters (autoplay mix).  yt-dlp interprets this as
a playlist and downloads dozens of videos instead of the selected one.

Fix (defense in depth):
- JS overlay: build a clean URL with only ?v= parameter
- Python: _clean_youtube_url() strips playlist params as safety net

Cancel fix:
- Create subprocess in new process group (CREATE_NEW_PROCESS_GROUP
  on Windows, start_new_session on Unix)
- Kill entire process tree on cancel: taskkill /F /T on Windows,
  os.killpg on Unix — ensures yt-dlp/ffmpeg children are terminated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Queue → Console:
- Rename "Queue" sidebar entry and tab header to "Console"

YouTube URL display:
- Show compact "YT: /watch?v=abc123" instead of full URL in sidebar

Fix output folder bug:
- Remove "Output Folder" from Settings tab (was duplicating Preferences)
- Only Preferences tab manages output_folder — no more conflicts
- Rename "Default Output Folder" → "Output Folder" in Preferences

Fix LLM settings duplication:
- Remove API Base URL and API Key from Settings tab
- Settings only has LLM toggle + model selector
- API credentials are managed exclusively in Preferences
- Info label directs users to Preferences for API configuration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Accidentally removed during Output Folder cleanup — the Paths section
still needs QPushButton (Browse) and QFileDialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace all visible and code-level platform name references with
  neutral terms (Video, video URL, etc.) to avoid trademark issues
- Replace Unicode hieroglyph icons (↺, ↻, ◀, ▶) with Qt standard
  icons (SP_ArrowBack, SP_ArrowForward, SP_BrowserReload,
  SP_DialogResetButton) for clean platform-native appearance
- Fix output folder Browse dialog rejecting valid paths on Windows
  by using DontUseNativeDialog option
- Rename internal APIs: youtube_cookie_count → video_cookie_count,
  has_youtube_cookies → has_video_cookies, set_youtube_input →
  set_video_input, _YOUTUBE_URL_RE → _VIDEO_URL_RE, etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@MrDix

MrDix commented Mar 19, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 19, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@MrDix
MrDix merged commit e15bf67 into main Mar 19, 2026
1 check passed
@MrDix
MrDix deleted the feat/pyside6-gui branch March 19, 2026 04:12
@MrDix MrDix changed the title Add PySide6 GUI with YouTube browser and full settings Add PySide6 GUI with video browser and full settings Mar 19, 2026
MrDix added a commit that referenced this pull request Mar 19, 2026
Complete PySide6 desktop GUI with sidebar navigation, dark theme,
embedded video browser (QWebEngineView) with Convert overlay,
system keyring for API keys, cookie management with Netscape export,
all CLI parameters as form controls, real-time conversion log,
and Docker/Podman compose configurations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
MrDix added a commit that referenced this pull request Jul 4, 2026
Complete PySide6 desktop GUI with sidebar navigation, dark theme,
embedded video browser (QWebEngineView) with Convert overlay,
system keyring for API keys, cookie management with Netscape export,
all CLI parameters as form controls, real-time conversion log,
and Docker/Podman compose configurations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant