Sync from development - prepare for v0.5.0

This commit is contained in:
Omni
2026-03-13 14:43:25 +00:00
parent 411addeea2
commit 3556914560
179 changed files with 7126 additions and 1806 deletions

View File

@@ -0,0 +1,466 @@
"""
Manual Download Dialog
Shown when the engine requires manual downloads (non-premium or forced-manual
archives). Displays all pending items in a scrollable table, manages browser
tab concurrency, and coordinates with ManualDownloadManager.
"""
import logging
from pathlib import Path
from typing import Optional
from PySide6.QtCore import Qt, Signal, QObject
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QSpinBox, QFrame, QTableWidget, QTableWidgetItem, QHeaderView,
QProgressBar, QFileDialog, QSizePolicy,
)
from PySide6.QtGui import QFont
from jackify.backend.services.manual_download_manager import ManualDownloadManager, DownloadItem
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
logger = logging.getLogger(__name__)
_STATUS_LABELS = {
'pending': 'Pending',
'browser_opened': 'Browser Opened',
'validating': 'Validating...',
'complete': 'Complete',
'deferred': 'Deferred',
'skipped': 'Skipped',
'error': 'Error',
}
_STATUS_COLOURS = {
'pending': '#808080',
'browser_opened': '#3498db',
'validating': '#f39c12',
'complete': '#27ae60',
'deferred': '#e67e22',
'skipped': '#e67e22',
'error': '#e74c3c',
}
# Column indices
_COL_MOD = 0
_COL_NAME = 1
_COL_SIZE = 2
_COL_STATUS = 3
def _fmt_size(n: int) -> str:
if n <= 0:
return ''
for unit in ('B', 'KB', 'MB', 'GB'):
if n < 1024:
return f"{n:.0f} {unit}"
n /= 1024
return f"{n:.1f} TB"
class _Bridge(QObject):
"""Tiny bridge so worker-thread callbacks can update the Qt table safely."""
item_updated = Signal(object) # DownloadItem
all_done = Signal(int, int) # completed, skipped
class ManualDownloadDialog(QDialog):
"""
Displays all pending manual downloads and coordinates the download workflow.
Non-modal so the install log remains visible.
"""
def __init__(
self,
manager: ManualDownloadManager,
modlist_name: str = '',
watch_directory: Optional[Path] = None,
concurrent_limit: int = 2,
parent=None,
):
super().__init__(parent)
self._manager = manager
self._modlist_name = modlist_name
self._watch_dir = watch_directory or (Path.home() / 'Downloads')
self._paused = False
self._started = False
self._initial_concurrent_limit = max(1, min(5, int(concurrent_limit)))
# Row index by file_name for fast updates
self._row_map: dict[str, int] = {}
# Bridge for thread-safe table updates
self._bridge = _Bridge()
self._bridge.item_updated.connect(self._on_item_updated_slot)
self._bridge.all_done.connect(self._on_all_done_slot)
# Preserve any existing manager callbacks so workflow controllers still
# receive completion events after the dialog updates its own UI.
prev_item_updated = self._manager._on_item_updated
prev_all_done = self._manager._on_all_done
def _emit_item_updated(item):
self._bridge.item_updated.emit(item)
if prev_item_updated:
prev_item_updated(item)
def _emit_all_done(completed: int, skipped: int):
self._bridge.all_done.emit(completed, skipped)
if prev_all_done:
prev_all_done(completed, skipped)
self._manager._on_item_updated = _emit_item_updated
self._manager._on_all_done = _emit_all_done
self.setWindowTitle("Manual Downloads Required")
self.setMinimumSize(760, 500)
self.setModal(False)
self._build_ui()
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def showEvent(self, event) -> None:
super().showEvent(event)
if not self._started:
# Keep the workflow idle until the user explicitly clicks Start.
# Start backend services in paused mode so watcher/precheck are ready
# without opening browser tabs yet.
self._paused = False
self._manager.pause()
self._manager.start()
self._start_pause_btn.setText("Start")
self._progress_label.setText("Ready - click Start to begin opening download tabs")
def load_items(self, items: list[DownloadItem]) -> None:
"""
Populate or refresh the table from a list of DownloadItems.
On subsequent loop iterations the manager passes its full item list
(including previously-completed rows), so we update existing rows and
append only genuinely new ones rather than rebuilding the table.
"""
new_items = [i for i in items if i.file_name not in self._row_map]
existing_items = [i for i in items if i.file_name in self._row_map]
# Update existing rows without disabling updates (usually few on repeat iterations)
for item in existing_items:
self._update_row(self._row_map[item.file_name], item)
# Batch-insert new rows with viewport updates suspended to avoid O(n²) repaints
if new_items:
self._table.setUpdatesEnabled(False)
try:
start_row = self._table.rowCount()
self._table.setRowCount(start_row + len(new_items))
for i, item in enumerate(new_items):
self._fill_row(start_row + i, item)
self._row_map[item.file_name] = start_row + i
finally:
self._table.setUpdatesEnabled(True)
self._table.viewport().update()
self._refresh_header()
# If user already started the workflow and engine enters another manual loop,
# continue opening tabs for newly-pending items automatically.
if self._started and not self._paused:
self._manager.resume()
def update_item(self, item: DownloadItem) -> None:
"""Called from any thread - bridges to Qt slot."""
self._bridge.item_updated.emit(item)
# ------------------------------------------------------------------
# Build UI
# ------------------------------------------------------------------
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(16, 16, 16, 16)
root.setSpacing(10)
# Header
hdr = QFrame()
hdr.setFrameShape(QFrame.StyledPanel)
hdr.setStyleSheet("QFrame { background: #1e2228; border-radius: 8px; border: 1px solid #333; }")
hdr_layout = QVBoxLayout(hdr)
hdr_layout.setContentsMargins(12, 10, 12, 10)
hdr_layout.setSpacing(6)
self._title_label = QLabel(f"Modlist: {self._modlist_name or 'Unknown'}")
self._title_label.setStyleSheet("color: #e0e0e0; font-size: 14px; font-weight: 600;")
hdr_layout.addWidget(self._title_label)
self._progress_label = QLabel("Preparing...")
self._progress_label.setStyleSheet("color: #aaaaaa; font-size: 12px;")
hdr_layout.addWidget(self._progress_label)
self._progress_bar = QProgressBar()
self._progress_bar.setRange(0, 100)
self._progress_bar.setValue(0)
self._progress_bar.setStyleSheet(
f"QProgressBar {{ border: 1px solid #444; border-radius: 4px; background: #2c2c2c; "
f"height: 12px; color: #d7e3f4; font-weight: 600; }}"
f"QProgressBar::chunk {{ background: {JACKIFY_COLOR_BLUE}; border-radius: 3px; }}"
)
hdr_layout.addWidget(self._progress_bar)
root.addWidget(hdr)
# Table
self._table = QTableWidget()
self._table.setColumnCount(4)
self._table.setHorizontalHeaderLabels(['Mod', 'File', 'Size', 'Status'])
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Fixed)
self._table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Fixed)
self._table.setColumnWidth(2, 90)
self._table.setColumnWidth(3, 130)
self._table.setSelectionBehavior(QTableWidget.SelectRows)
self._table.setEditTriggers(QTableWidget.NoEditTriggers)
self._table.setAlternatingRowColors(True)
self._table.verticalHeader().setVisible(False)
self._table.cellDoubleClicked.connect(self._on_row_double_clicked)
self._table.setStyleSheet(
"QTableWidget { background: #1a1d23; alternate-background-color: #1e2228; "
"color: #d0d0d0; gridline-color: #333; border: 1px solid #333; border-radius: 4px; }"
"QHeaderView::section { background: #252830; color: #aaa; border: none; "
"padding: 4px; font-size: 11px; }"
)
root.addWidget(self._table)
# Controls row
ctrl = QHBoxLayout()
ctrl.setSpacing(12)
ctrl.addWidget(QLabel("Concurrent tabs:"))
self._concurrent_spin = QSpinBox()
self._concurrent_spin.setRange(1, 5)
self._concurrent_spin.setValue(self._initial_concurrent_limit)
self._concurrent_spin.setFixedWidth(60)
self._concurrent_spin.valueChanged.connect(self._on_concurrent_changed)
ctrl.addWidget(self._concurrent_spin)
ctrl.addSpacing(16)
ctrl.addWidget(QLabel("Watch folder:"))
self._folder_label = QLabel(str(self._watch_dir))
self._folder_label.setStyleSheet("color: #aaa; font-size: 11px;")
self._folder_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
ctrl.addWidget(self._folder_label)
folder_btn = QPushButton("...")
folder_btn.setFixedSize(32, 28)
folder_btn.clicked.connect(self._on_pick_folder)
ctrl.addWidget(folder_btn)
root.addLayout(ctrl)
watch_hint = QLabel(
"Jackify watches this folder for newly downloaded archives, validates them, "
"then moves valid files into your modlist downloads folder automatically. "
"Double-click a row (or use Open Selected) to reopen a URL if you closed a tab."
)
watch_hint.setWordWrap(True)
watch_hint.setStyleSheet("color: #8f98a3; font-size: 11px;")
root.addWidget(watch_hint)
# Action buttons
btn_row = QHBoxLayout()
btn_row.setSpacing(10)
self._retry_btn = QPushButton("Retry Deferred (0)")
self._retry_btn.setEnabled(False)
self._retry_btn.clicked.connect(self._on_retry_skipped)
btn_row.addWidget(self._retry_btn)
self._defer_btn = QPushButton("Defer Selected")
self._defer_btn.clicked.connect(self._on_defer_selected)
btn_row.addWidget(self._defer_btn)
self._open_selected_btn = QPushButton("Open Selected")
self._open_selected_btn.clicked.connect(self._on_open_selected)
btn_row.addWidget(self._open_selected_btn)
btn_row.addStretch()
self._start_pause_btn = QPushButton("Start")
self._start_pause_btn.clicked.connect(self._on_start_pause_clicked)
btn_row.addWidget(self._start_pause_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.setStyleSheet(
"QPushButton { background: #7f2020; color: white; border: none; "
"border-radius: 4px; padding: 6px 16px; }"
"QPushButton:hover { background: #9b2828; }"
)
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(cancel_btn)
root.addLayout(btn_row)
# ------------------------------------------------------------------
# Table helpers
# ------------------------------------------------------------------
def _fill_row(self, row: int, item: DownloadItem) -> None:
"""Populate cells for a pre-allocated row (row must already exist in the table)."""
from PySide6.QtGui import QColor
self._table.setItem(row, _COL_MOD, QTableWidgetItem(item.mod_name))
self._table.setItem(row, _COL_NAME, QTableWidgetItem(item.file_name))
self._table.setItem(row, _COL_SIZE, QTableWidgetItem(_fmt_size(item.expected_size)))
colour = _STATUS_COLOURS.get(item.status, '#808080')
status_cell = QTableWidgetItem(_STATUS_LABELS.get(item.status, item.status))
status_cell.setForeground(QColor(colour))
if item.error_message:
status_cell.setToolTip(item.error_message)
self._table.setItem(row, _COL_STATUS, status_cell)
def _update_row(self, row: int, item: DownloadItem) -> None:
from PySide6.QtGui import QColor
status_cell = self._table.item(row, _COL_STATUS)
if status_cell:
status_cell.setText(_STATUS_LABELS.get(item.status, item.status))
status_cell.setForeground(QColor(_STATUS_COLOURS.get(item.status, '#808080')))
status_cell.setToolTip(item.error_message or "")
def _refresh_header(self) -> None:
items = self._manager.items
total = len(items)
complete = sum(1 for i in items if i.status == 'complete')
skipped = sum(1 for i in items if i.status == 'skipped')
remaining = total - complete - skipped
pct = int(complete / total * 100) if total > 0 else 0
self._progress_bar.setValue(pct)
self._progress_label.setText(
f"{complete} of {total} complete | {skipped} deferred | {remaining} remaining"
)
self._retry_btn.setText(f"Retry Deferred ({skipped})")
self._retry_btn.setEnabled(skipped > 0)
# ------------------------------------------------------------------
# Slots
# ------------------------------------------------------------------
def _on_item_updated_slot(self, item: DownloadItem) -> None:
row = self._row_map.get(item.file_name)
if row is not None:
self._update_row(row, item)
self._refresh_header()
def _on_concurrent_changed(self, value: int) -> None:
self._manager.set_concurrent_limit(value)
try:
cfg = ConfigHandler()
cfg.set("manual_download_concurrent_limit", int(value))
cfg.save_config()
except Exception:
logger.debug("Could not persist manual_download_concurrent_limit", exc_info=True)
def _on_pick_folder(self) -> None:
chosen = QFileDialog.getExistingDirectory(self, "Select watch folder", str(self._watch_dir))
if chosen:
from jackify.backend.services.download_watcher_service import WatcherConfig
self._watch_dir = Path(chosen)
self._folder_label.setText(chosen)
self._manager._watch_dir = self._watch_dir
self._manager._watcher._config.watch_directory = self._watch_dir
self._manager._watcher._known = {}
try:
cfg = ConfigHandler()
cfg.set("manual_download_watch_directory", str(self._watch_dir))
cfg.save_config()
except Exception:
logger.debug("Could not persist manual_download_watch_directory", exc_info=True)
def _on_start_pause_clicked(self) -> None:
if not self._started:
self._started = True
self._paused = False
self._start_pause_btn.setText("Pause")
self._manager.resume()
return
if not self._paused:
self._paused = True
self._start_pause_btn.setText("Resume")
self._manager.pause()
else:
self._paused = False
self._start_pause_btn.setText("Pause")
self._manager.resume()
def _on_retry_skipped(self) -> None:
with self._manager._lock:
for item in self._manager._items:
if item.status in ('deferred', 'skipped'):
item.status = 'pending'
item.needs_user_retry = False
row = self._row_map.get(item.file_name)
if row is not None:
self._update_row(row, item)
self._manager._open_next_tabs()
self._refresh_header()
def _on_defer_selected(self) -> None:
row = self._table.currentRow()
if row < 0:
return
file_item = self._table.item(row, _COL_NAME)
if file_item is None:
return
file_name = file_item.text().strip()
if not file_name:
return
self._manager.skip_item(file_name)
def _on_open_selected(self) -> None:
row = self._table.currentRow()
if row < 0:
return
file_item = self._table.item(row, _COL_NAME)
if file_item is None:
return
file_name = file_item.text().strip()
if not file_name:
return
self._manager.reopen_item(file_name)
def _on_row_double_clicked(self, row: int, _column: int) -> None:
file_item = self._table.item(row, _COL_NAME)
if file_item is None:
return
file_name = file_item.text()
if not file_name:
return
self._manager.reopen_item(file_name)
def _on_all_done_slot(self, completed: int, skipped: int) -> None:
from PySide6.QtCore import QTimer
self._progress_label.setText(
f"All downloads complete ({completed} accepted, {skipped} deferred) — closing..."
)
# Raise now while the dialog is still visible so the user sees the completion state
self._raise_main_window()
QTimer.singleShot(2000, self._close_and_refocus)
def _close_and_refocus(self) -> None:
self.close()
# Closing a non-modal dialog can hand focus back to whatever was behind it
self._raise_main_window()
def _raise_main_window(self) -> None:
try:
win = self.window()
if win:
win.raise_()
win.activateWindow()
except Exception:
pass
def closeEvent(self, event) -> None:
# Don't stop the manager on close - install continues
event.accept()