mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-07 20:27:45 +02:00
480 lines
19 KiB
Python
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}")
|