Files
Jackify/jackify/backend/services/manual_download_manager_runtime_mixin.py
2026-03-13 14:43:25 +00:00

480 lines
19 KiB
Python

from __future__ import annotations
"""Internal runtime methods for ManualDownloadManager."""
import json
import logging
import subprocess
from pathlib import Path
from typing import Optional
from jackify.backend.services.file_validator_service import ValidationResult
logger = logging.getLogger(__name__)
class ManualDownloadManagerRuntimeMixin:
"""Mixin containing browser/watcher/validation runtime methods."""
def _open_next_tabs(self, force_user_start: bool = False) -> None:
to_open = []
to_notify = []
with self._lock:
if not self._started or self._paused:
return
if self._startup_precheck_pending > 0 and not force_user_start:
return
while self._active_tabs < self._limit:
item = self._next_pending(include_retry=force_user_start)
if item is None:
break
if force_user_start and item.needs_user_retry:
item.needs_user_retry = False
item.error_message = None
item.status = 'browser_opened'
self._active_tabs += 1
to_notify.append(item)
to_open.append(item)
active_tabs = self._active_tabs
pending_left = sum(1 for i in self._items if i.status == 'pending')
if to_open:
self._diag(
"MDL-1010",
"Opening next manual download tab window",
opening_count=len(to_open),
active_tabs=active_tabs,
pending_after_schedule=pending_left,
)
# Notify outside the lock to prevent GUI callbacks from re-entering manager state.
for item in to_notify:
self._notify(item)
# Open browser tabs outside the lock so Popen/fork doesn't stall lock holders
for item in to_open:
opened, error_message = self._open_browser(item)
if opened:
continue
item_to_notify: Optional[DownloadItem] = None
with self._lock:
# Revert failed launch so the row does not falsely remain "Browser Opened".
current = self._item_by_name(item.file_name)
if current and current.status == 'browser_opened':
current.status = 'pending'
if self._active_tabs > 0:
self._active_tabs -= 1
current.error_message = error_message
if error_message and "No URL available" in error_message:
current.needs_user_retry = True
item_to_notify = current
if item_to_notify is not None:
self._notify(item_to_notify)
self._diag(
"MDL-9001",
"Automatic browser launch failed for manual download item",
level="warning",
file_name=item.file_name,
reason=error_message or "unknown launcher failure",
)
def _next_pending(self, include_retry: bool = False) -> Optional[DownloadItem]:
for item in self._items:
if item.status != 'pending':
continue
if item.needs_user_retry and not include_retry:
continue
return item
return None
def _open_browser(self, item: DownloadItem) -> tuple[bool, Optional[str]]:
url = item.nexus_url
if not url:
msg = "No URL available for manual download item"
logger.warning(f"{msg}: {item.file_name}")
return False, msg
# Linux desktop launch fallbacks. xdg-open should cover most environments,
# but keep alternates for distributions where handlers differ.
launch_cmds = (
['xdg-open', url],
['gio', 'open', url],
['sensible-browser', url],
)
launch_errors: list[str] = []
for cmd in launch_cmds:
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
start_new_session=True,
)
except OSError as e:
launch_errors.append(f"{cmd[0]} not available: {e}")
continue
try:
rc = proc.wait(timeout=3)
except subprocess.TimeoutExpired:
# Launcher still running after handoff window; treat as success.
logger.debug(f"Opened browser for: {item.file_name} via {cmd[0]}")
return True, None
if rc == 0:
logger.debug(f"Opened browser for: {item.file_name} via {cmd[0]}")
return True, None
stderr_tail = ""
try:
stderr_tail = (proc.stderr.read() or b"").decode("utf-8", errors="replace").strip()
except Exception:
stderr_tail = ""
launch_errors.append(f"{cmd[0]} exited {rc}{(': ' + stderr_tail) if stderr_tail else ''}")
msg = f"Could not open browser automatically for {item.file_name}"
logger.error(f"{msg}. Launch attempts: {' | '.join(launch_errors)}")
return False, msg
def _on_candidate(self, path: Path, hint: dict, from_startup_precheck: bool = False) -> bool:
"""Called by watcher after debounce when a potential match is found."""
file_name = hint.get('file_name', '')
item_to_notify: Optional[DownloadItem] = None
reject_reason = ""
had_browser_slot = False
with self._lock:
item = self._item_by_name(file_name)
if item is None:
reject_reason = "unknown_item"
elif item.status in ('complete', 'skipped'):
reject_reason = f"terminal_status:{item.status}"
elif item.status == 'validating':
reject_reason = "already_validating"
if reject_reason:
self._diag(
"MDL-1022",
"Candidate ignored",
file_name=file_name or "missing",
source_path=str(path),
from_precheck=from_startup_precheck,
reason=reject_reason,
)
return False
had_browser_slot = item.status == 'browser_opened'
item.status = 'validating'
item_to_notify = item
if item_to_notify is not None:
self._notify(item_to_notify)
self._diag(
"MDL-1020",
"Candidate queued for validation",
file_name=file_name or "missing",
source_path=str(path),
from_precheck=from_startup_precheck,
)
# Pass the engine's canonical filename as dest_name so that if the browser
# stripped a leading dot, the file is renamed correctly on move.
canonical_name = hint.get('file_name') or None
dest_name = canonical_name if canonical_name and canonical_name != path.name else None
self._validator.validate_async(
file_path=path,
expected_hash=hint.get('expected_hash', ''),
modlist_download_dir=self._dl_dir,
on_result=lambda result, dest: self._on_validation_result(
file_name,
result,
dest,
from_startup_precheck=from_startup_precheck,
had_browser_slot=had_browser_slot,
),
dest_name=dest_name,
)
return True
def _on_validation_result(
self,
file_name: str,
result: ValidationResult,
dest: Optional[Path],
from_startup_precheck: bool = False,
had_browser_slot: bool = False,
) -> None:
item_to_notify: Optional[DownloadItem] = None
validation_failed = False
completed_now = False
precheck_ready = False
expected_hash = ""
mod_id = 0
file_id = 0
source_path = str(result.file_path) if getattr(result, "file_path", None) else ""
computed_hash = (result.computed_hash or "").lower() if result.computed_hash else ""
with self._lock:
item = self._item_by_name(file_name)
if item is None:
return
expected_hash = (item.expected_hash or "").lower()
mod_id = item.mod_id
file_id = item.file_id
if result.matches and dest:
item.status = 'complete'
item.local_path = str(dest)
item.needs_user_retry = False
if had_browser_slot and self._active_tabs > 0:
self._active_tabs -= 1
item_to_notify = item
completed_now = True
else:
# Hash mismatch or validation error — revert to pending so the
# sliding window can re-open a browser tab and the watcher can
# re-validate if the user downloads the correct file.
item.status = 'pending'
msg = result.error or f"Hash mismatch (got {result.computed_hash})"
item.error_message = msg
logger.warning(f"Validation failed for {file_name}: {msg}")
if had_browser_slot and self._active_tabs > 0:
self._active_tabs -= 1
item_to_notify = item
validation_failed = True
if from_startup_precheck and self._startup_precheck_pending > 0:
self._startup_precheck_pending -= 1
precheck_ready = self._startup_precheck_pending == 0
if item_to_notify is not None:
self._notify(item_to_notify)
if completed_now:
self._diag(
"MDL-1021",
"Archive validated and accepted",
file_name=file_name,
source_path=source_path or "missing",
destination_path=str(dest) if dest else "missing",
expected_hash=expected_hash or "missing",
computed_hash=computed_hash or "missing",
from_precheck=from_startup_precheck,
mod_id=mod_id,
file_id=file_id,
)
self._maybe_log_progress_summary()
if precheck_ready:
self._diag("MDL-1017", "Startup precheck validation complete; opening tabs")
self._open_next_tabs()
if validation_failed:
self._refresh_watcher_pending_items()
if not from_startup_precheck:
self._open_next_tabs()
self._diag(
"MDL-9002",
"Archive validation failed",
level="warning",
file_name=file_name,
expected_hash=expected_hash or "missing",
computed_hash=computed_hash or "missing",
source_path=source_path or "missing",
mod_id=mod_id,
file_id=file_id,
from_precheck=from_startup_precheck,
reason=result.error or "hash mismatch",
)
return
# Update watcher pending list (remove completed item, keep other in-flight items).
self._refresh_watcher_pending_items()
if not from_startup_precheck:
self._open_next_tabs()
self._check_all_done()
def _check_all_done(self) -> None:
with self._lock:
remaining = [i for i in self._items if i.status not in ('complete', 'deferred', 'skipped', 'error')]
if remaining:
return
completed = sum(1 for i in self._items if i.status == 'complete')
skipped = sum(1 for i in self._items if i.status in ('deferred', 'skipped'))
self._diag("MDL-1011", "Manual download phase completed", completed=completed, skipped=skipped)
if self._on_all_done:
self._on_all_done(completed, skipped)
if self._on_send_continue:
self._diag("MDL-1012", "Sending continue command to engine")
self._on_send_continue()
def _item_by_name(self, file_name: str) -> Optional[DownloadItem]:
for item in self._items:
if item.file_name == file_name:
return item
return None
def _refresh_watcher_pending_items(self) -> None:
"""Keep watcher tracking all non-terminal items, not only pure 'pending' ones."""
with self._lock:
pending_items = [
{'file_name': i.file_name, 'expected_hash': i.expected_hash, 'expected_size': i.expected_size}
for i in self._items
if i.status not in ('complete', 'error')
]
pending_count = len(pending_items)
sample_pending = [i['file_name'] for i in pending_items[:5]]
self._watcher.set_pending_items(pending_items)
self._diag(
"MDL-1019",
"Watcher pending list refreshed",
pending_count=pending_count,
pending_sample=json.dumps(sample_pending, ensure_ascii=True),
)
def _ingest_existing_files(self) -> int:
"""
Pre-check watch/modlist directories for already-present archives so users
do not need to re-download files that already exist.
"""
dirs: list[Path] = []
if self._watch_dir.is_dir():
dirs.append(self._watch_dir)
if self._dl_dir.is_dir() and self._dl_dir != self._watch_dir:
dirs.append(self._dl_dir)
if not dirs:
return 0
existing_files: list[Path] = []
for d in dirs:
try:
for p in d.iterdir():
if p.is_file() and p.suffix not in ('.part', '.crdownload', '.tmp'):
existing_files.append(p)
except OSError as e:
logger.warning(f"[MDL-9021] Precheck scan error: dir={d} reason={e}")
continue
if not existing_files:
self._diag("MDL-1023", "Startup precheck found no candidate files", scan_dirs=len(dirs))
return 0
exact_map: dict[str, Path] = {}
for p in existing_files:
exact_map.setdefault(p.name.lower(), p)
with self._lock:
targets = [
{'file_name': i.file_name, 'expected_hash': i.expected_hash, 'expected_size': i.expected_size}
for i in self._items
if i.status not in ('complete', 'error')
]
self._diag(
"MDL-1024",
"Startup precheck scan summary",
scan_dirs=len(dirs),
discovered_files=len(existing_files),
pending_targets=len(targets),
discovered_sample=json.dumps([p.name for p in existing_files[:5]], ensure_ascii=True),
target_sample=json.dumps([t.get('file_name', '') for t in targets[:5]], ensure_ascii=True),
)
matched = 0
used_paths: set[Path] = set()
for hint in targets:
name = hint['file_name']
exact = exact_map.get(name.lower())
if exact is None:
# Leading-dot normalization: browser may strip a leading dot that
# the engine uses in its canonical filename.
stripped = name.lower().lstrip('.')
if stripped != name.lower():
exact = exact_map.get(stripped)
if exact is None or exact in used_paths:
continue
used_paths.add(exact)
if self._on_candidate(exact, hint, from_startup_precheck=True):
matched += 1
if matched:
logger.info(f"[MDL-1025] Startup precheck queued {matched} archive(s) for validation")
else:
self._diag("MDL-1025", "Startup precheck found zero exact filename matches")
return matched
def reopen_item(self, file_name: str) -> bool:
"""Re-open a specific item's URL (e.g. if user closed browser tab accidentally)."""
notify_item: Optional[DownloadItem] = None
with self._lock:
item = self._item_by_name(file_name)
if item is None:
return False
if item.status in ('complete', 'skipped'):
return False
if item.status != 'browser_opened':
item.status = 'browser_opened'
item.needs_user_retry = False
self._active_tabs += 1
notify_item = item
if notify_item is not None:
self._notify(notify_item)
if item is None:
return False
opened, error = self._open_browser(item)
if not opened:
revert_item: Optional[DownloadItem] = None
with self._lock:
current = self._item_by_name(file_name)
if current is not None and current.status == 'browser_opened':
current.status = 'pending'
current.needs_user_retry = True
if self._active_tabs > 0:
self._active_tabs -= 1
revert_item = current
if revert_item is not None:
self._notify(revert_item)
self._diag(
"MDL-9011",
"Manual reopen failed",
level="warning",
file_name=file_name,
reason=error or "unknown launcher failure",
)
return False
self._diag("MDL-1018", "Manual item URL re-opened by user", file_name=file_name)
return True
def _maybe_log_progress_summary(self) -> None:
with self._lock:
complete = sum(1 for i in self._items if i.status == 'complete')
skipped = sum(1 for i in self._items if i.status in ('deferred', 'skipped'))
pending = sum(1 for i in self._items if i.status == 'pending')
validating = sum(1 for i in self._items if i.status == 'validating')
opened = sum(1 for i in self._items if i.status == 'browser_opened')
total = len(self._items)
if complete == self._last_progress_log_completed:
return
if complete in (1, total) or complete % 10 == 0:
self._last_progress_log_completed = complete
self._diag(
"MDL-1013",
"Manual download progress summary",
total=total,
complete=complete,
browser_opened=opened,
validating=validating,
pending=pending,
skipped=skipped,
needs_retry=sum(1 for i in self._items if i.needs_user_retry),
)
def _diag(self, code: str, message: str, level: str = "info", **ctx) -> None:
details = " ".join(f"{k}={v}" for k, v in ctx.items())
text = f"[{code}] run={self._run_id} {message}"
if details:
text = f"{text} | {details}"
if level == "warning":
logger.warning(text)
elif level == "error":
logger.error(text)
else:
logger.info(text)
def _notify(self, item: DownloadItem) -> None:
if self._on_item_updated:
try:
self._on_item_updated(item)
except Exception as e:
logger.debug(f"on_item_updated callback error: {e}")