mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 03:17:44 +02:00
480 lines
18 KiB
Python
480 lines
18 KiB
Python
"""
|
|
CLI manual download flow.
|
|
|
|
Handles the interactive terminal experience when the engine emits a batch of
|
|
files requiring manual download. Uses the same backend services as the GUI path
|
|
(ManualDownloadManager, DownloadWatcherService, FileValidatorService) but
|
|
outputs status to the terminal and reads simple keyboard commands.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import queue
|
|
import shutil
|
|
import threading
|
|
import logging
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Callable, Optional
|
|
|
|
from jackify.backend.services.manual_download_manager import ManualDownloadManager, DownloadItem
|
|
from jackify.backend.services.download_watcher_service import WatcherConfig
|
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
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 CliManualDownloadFlow:
|
|
"""
|
|
Blocking CLI flow for manual downloads. Returns when all items are done
|
|
(complete or skipped) and the continue command has been written to the
|
|
engine's stdin pipe.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
items: list[dict],
|
|
loop_iteration: int,
|
|
download_dir: Path,
|
|
stdin_write: Callable[[str], bool],
|
|
output_callback: Optional[Callable[[str], None]] = None,
|
|
concurrent_limit: int = 2,
|
|
):
|
|
self._stdin_write = stdin_write
|
|
self._output = output_callback or print
|
|
self._done_event = threading.Event()
|
|
self._config_handler = ConfigHandler()
|
|
self._command_queue: queue.Queue[str] = queue.Queue()
|
|
self._last_rendered_snapshot: Optional[str] = None
|
|
self._last_render_time = 0.0
|
|
self._completed_successfully = False
|
|
self._interactive_tty = bool(getattr(sys.__stdout__, "isatty", lambda: False)())
|
|
self._terminal = sys.__stdout__ if self._interactive_tty else None
|
|
self._screen_lines = 0
|
|
self._status_dirty = True
|
|
self._notices: list[str] = []
|
|
self._startup_render_blocked = False
|
|
|
|
configured_watch = self._config_handler.get("manual_download_watch_directory", None)
|
|
watch_dir = None
|
|
if configured_watch:
|
|
cfg_path = Path(str(configured_watch)).expanduser()
|
|
if cfg_path.is_dir():
|
|
watch_dir = cfg_path
|
|
if watch_dir is None:
|
|
xdg_dl = os.environ.get('XDG_DOWNLOAD_DIR', '')
|
|
watch_dir = Path(xdg_dl) if (xdg_dl and Path(xdg_dl).is_dir()) else Path.home() / 'Downloads'
|
|
|
|
self._manager = ManualDownloadManager(
|
|
modlist_download_dir=download_dir,
|
|
watch_directory=watch_dir,
|
|
concurrent_limit=concurrent_limit,
|
|
on_item_updated=self._on_item_updated,
|
|
on_send_continue=self._on_all_done,
|
|
on_all_done=self._on_all_done_counts,
|
|
)
|
|
self._manager.load_items(items, loop_iteration)
|
|
self._total = len(self._manager.items)
|
|
self._watch_dir = watch_dir
|
|
|
|
def run(self) -> bool:
|
|
"""Block until all items are complete/skipped and continue is sent."""
|
|
if not self._confirm_start():
|
|
self._output("Manual download phase cancelled.")
|
|
return False
|
|
self._startup_render_blocked = True
|
|
try:
|
|
self._manager.start()
|
|
finally:
|
|
self._startup_render_blocked = False
|
|
self._render_status(force=True)
|
|
if sys.stdin.isatty():
|
|
threading.Thread(target=self._read_commands, daemon=True).start()
|
|
else:
|
|
self._output("[interactive commands unavailable: non-interactive stdin]")
|
|
|
|
while not self._done_event.is_set():
|
|
self._handle_pending_commands()
|
|
self._render_status()
|
|
self._done_event.wait(timeout=0.25)
|
|
|
|
self._manager.stop()
|
|
return self._completed_successfully
|
|
|
|
def _on_item_updated(self, item: DownloadItem) -> None:
|
|
status = {
|
|
'browser_opened': 'browser opened',
|
|
'validating': 'validating...',
|
|
'complete': '[OK]',
|
|
'deferred': '[deferred]',
|
|
'skipped': '[skipped]',
|
|
'error': '[error]',
|
|
}.get(item.status, item.status)
|
|
if not self._interactive_tty or item.status in ('error',):
|
|
self._output(f" {status:>14} {item.file_name}")
|
|
if item.error_message:
|
|
self._emit_notice(f"reason: {item.error_message}")
|
|
self._status_dirty = True
|
|
if self._startup_render_blocked or self._interactive_tty:
|
|
return
|
|
self._render_status(force=True)
|
|
|
|
def _on_all_done_counts(self, completed: int, skipped: int) -> None:
|
|
self._completed_successfully = completed == self._total and skipped == 0
|
|
self._emit_notice(f"All downloads done: {completed} complete, {skipped} skipped")
|
|
self._emit_notice("Signalling engine to continue...")
|
|
|
|
def _on_all_done(self) -> None:
|
|
self._stdin_write('{"command":"continue"}')
|
|
self._done_event.set()
|
|
|
|
def _retry_deferred(self) -> None:
|
|
retried = 0
|
|
with self._manager._lock:
|
|
for item in self._manager._items:
|
|
if item.status in ('deferred', 'skipped'):
|
|
item.status = 'pending'
|
|
item.needs_user_retry = False
|
|
item.error_message = None
|
|
retried += 1
|
|
if retried == 0:
|
|
self._emit_notice("[no deferred items to retry]")
|
|
return
|
|
self._emit_notice(f"[retried {retried} deferred item(s)]")
|
|
self._manager._open_next_tabs()
|
|
|
|
def _set_watch_folder(self, raw: str) -> None:
|
|
candidate = Path(raw).expanduser()
|
|
if not candidate.is_dir():
|
|
self._emit_notice(f"[invalid directory: {candidate}]")
|
|
return
|
|
self._watch_dir = candidate
|
|
self._manager._watch_dir = candidate
|
|
self._manager._watcher._config.watch_directory = candidate
|
|
# Force a fresh scan baseline for the newly-selected directory.
|
|
self._manager._watcher._known = {}
|
|
try:
|
|
self._config_handler.set("manual_download_watch_directory", str(candidate))
|
|
self._config_handler.save_config()
|
|
except Exception:
|
|
logger.debug("Could not persist manual_download_watch_directory", exc_info=True)
|
|
self._emit_notice(f"[watch folder set to {candidate}]")
|
|
self._status_dirty = True
|
|
self._render_status(force=True)
|
|
|
|
def _set_concurrency(self, raw: str) -> None:
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
self._emit_notice(f"[invalid number: {raw}]")
|
|
return
|
|
value = max(1, min(5, value))
|
|
self._manager.set_concurrent_limit(value)
|
|
try:
|
|
self._config_handler.set("manual_download_concurrent_limit", value)
|
|
self._config_handler.save_config()
|
|
except Exception:
|
|
logger.debug("Could not persist manual_download_concurrent_limit", exc_info=True)
|
|
self._emit_notice(f"[concurrency set to {value}]")
|
|
self._status_dirty = True
|
|
self._render_status(force=True)
|
|
|
|
def _read_commands(self) -> None:
|
|
while not self._done_event.is_set():
|
|
try:
|
|
line = sys.stdin.readline()
|
|
except Exception:
|
|
return
|
|
if not line:
|
|
return
|
|
self._command_queue.put(line.strip())
|
|
|
|
def _handle_pending_commands(self) -> None:
|
|
while True:
|
|
try:
|
|
command = self._command_queue.get_nowait()
|
|
except queue.Empty:
|
|
return
|
|
if not command:
|
|
continue
|
|
if self._handle_command(command):
|
|
return
|
|
|
|
def _handle_command(self, command: str) -> bool:
|
|
parts = command.split(maxsplit=1)
|
|
action = parts[0].lower()
|
|
arg = parts[1] if len(parts) > 1 else ""
|
|
|
|
if action == 'help':
|
|
self._print_help()
|
|
elif action in ('list', 'ls', 'status'):
|
|
self._render_status(force=True)
|
|
elif action == 'open':
|
|
self._open_item(arg)
|
|
elif action in ('defer', 'skip'):
|
|
self._defer_item(arg)
|
|
elif action == 'retry':
|
|
self._retry_deferred()
|
|
elif action == 'watch':
|
|
if not arg:
|
|
self._emit_notice(f"[watch folder: {self._watch_dir}]")
|
|
else:
|
|
self._set_watch_folder(arg)
|
|
elif action == 'pause':
|
|
self._manager.pause()
|
|
self._emit_notice("[paused]")
|
|
elif action == 'resume':
|
|
self._manager.resume()
|
|
self._emit_notice("[resumed]")
|
|
elif action in ('concurrency', 'tabs'):
|
|
if not arg:
|
|
self._emit_notice(f"[concurrency: {self._manager._limit}]")
|
|
else:
|
|
self._set_concurrency(arg)
|
|
elif action in ('quit', 'exit'):
|
|
self._emit_notice("Stopping - downloaded files are preserved for resume.")
|
|
self._manager.stop()
|
|
self._done_event.set()
|
|
return True
|
|
else:
|
|
self._emit_notice(f"[unknown command: {command}]")
|
|
self._print_help()
|
|
return False
|
|
|
|
def _print_help(self) -> None:
|
|
self._output("")
|
|
self._output("Commands:")
|
|
self._output(" list Show current status")
|
|
self._output(" open <index> Re-open a file in the browser")
|
|
self._output(" defer <index> Defer an active file")
|
|
self._output(" retry Retry all deferred files")
|
|
self._output(" watch <path> Change watched download folder")
|
|
self._output(" pause | resume Pause or resume auto-open")
|
|
self._output(" concurrency <1-5> Set concurrent browser tabs")
|
|
self._output(" quit Stop and preserve progress")
|
|
self._output("")
|
|
|
|
def _render_status(self, force: bool = False) -> None:
|
|
now = time.monotonic()
|
|
if not force and not self._status_dirty and (now - self._last_render_time) < 2.0:
|
|
return
|
|
with self._manager._lock:
|
|
items = list(self._manager._items)
|
|
complete = sum(1 for item in items if item.status == 'complete')
|
|
deferred = sum(1 for item in items if item.status in ('deferred', 'skipped'))
|
|
active = sum(1 for item in items if item.status == 'browser_opened')
|
|
validating = sum(1 for item in items if item.status == 'validating')
|
|
pending = sum(1 for item in items if item.status == 'pending')
|
|
paused = self._manager._paused
|
|
remaining = self._total - complete - deferred
|
|
snapshot = (
|
|
f"Watch: {self._watch_dir} | Complete: {complete}/{self._total} | "
|
|
f"Active: {active} | Validating: {validating} | Pending: {pending} | "
|
|
f"Deferred: {deferred} | Remaining: {remaining} | "
|
|
f"Tabs: {self._manager._limit} | {'Paused' if paused else 'Running'}"
|
|
)
|
|
if not force and snapshot == self._last_rendered_snapshot:
|
|
return
|
|
self._last_rendered_snapshot = snapshot
|
|
self._last_render_time = now
|
|
self._status_dirty = False
|
|
recheck_note = None
|
|
if self._manager._items:
|
|
first = self._manager._items[0]
|
|
if first.loop_iteration > 1:
|
|
recheck_note = f"Recheck {first.loop_iteration} - still missing: {self._total}"
|
|
lines = [
|
|
"",
|
|
*self._boxed_lines(
|
|
"Jackify CLI Download Manager",
|
|
[
|
|
f"Files required: {self._total}",
|
|
f"Concurrent browser tabs: {self._manager._limit}",
|
|
f"Watching: {self._watch_dir}",
|
|
*( [recheck_note] if recheck_note else [] ),
|
|
],
|
|
),
|
|
*self._boxed_lines(
|
|
"Action Required",
|
|
[
|
|
"Check your browser now.",
|
|
"Jackify may have opened Nexus download pages in the background.",
|
|
"Type `help` at any time for available commands.",
|
|
],
|
|
),
|
|
*self._boxed_lines("Status", [snapshot]),
|
|
*self._boxed_lines(
|
|
"Downloads",
|
|
[self._format_table_header(), *[self._format_item(item) for item in self._visible_items(items)]],
|
|
),
|
|
*self._boxed_lines(
|
|
"Commands",
|
|
["help | list | open <index> | defer <index> | retry | watch <path> | pause | resume | concurrency <1-5> | quit"],
|
|
),
|
|
*self._boxed_lines("Notices", self._notices[-3:] or ["None"]),
|
|
"",
|
|
]
|
|
if self._interactive_tty and self._terminal is not None:
|
|
self._redraw_terminal(lines)
|
|
else:
|
|
for line in lines:
|
|
self._output(line)
|
|
|
|
def _visible_items(self, items: list[DownloadItem]) -> list[DownloadItem]:
|
|
priority = {'browser_opened': 0, 'validating': 1, 'pending': 2, 'deferred': 3, 'skipped': 4, 'error': 5, 'complete': 6}
|
|
return sorted(items, key=lambda item: (priority.get(item.status, 9), item.index))[:14]
|
|
|
|
def _format_item(self, item: DownloadItem) -> str:
|
|
status = {
|
|
'browser_opened': 'OPEN ',
|
|
'validating': 'CHECK',
|
|
'pending': 'WAIT ',
|
|
'deferred': 'DEFER',
|
|
'skipped': 'SKIP ',
|
|
'error': 'ERROR',
|
|
'complete': 'DONE ',
|
|
}.get(item.status, item.status[:5].upper())
|
|
size = _fmt_size(item.expected_size).rjust(7) if item.expected_size else ' ' * 7
|
|
mod_name = self._truncate(item.mod_name or "", 24).ljust(24)
|
|
file_name = self._truncate(item.file_name, 32).ljust(32)
|
|
return f"{item.index:>3}/{item.total:<3} {status} {size} {mod_name} {file_name}"
|
|
|
|
def _find_item(self, raw: str) -> Optional[DownloadItem]:
|
|
if not raw:
|
|
self._emit_notice("[missing item index]")
|
|
return None
|
|
if raw.isdigit():
|
|
target_index = int(raw)
|
|
for item in self._manager.items:
|
|
if item.index == target_index:
|
|
return item
|
|
low = raw.lower()
|
|
for item in self._manager.items:
|
|
if item.file_name.lower() == low:
|
|
return item
|
|
self._emit_notice(f"[item not found: {raw}]")
|
|
return None
|
|
|
|
def _open_item(self, raw: str) -> None:
|
|
item = self._find_item(raw)
|
|
if not item:
|
|
return
|
|
if self._manager.reopen_item(item.file_name):
|
|
self._emit_notice(f"[opened] {item.file_name}")
|
|
else:
|
|
self._emit_notice(f"[could not open] {item.file_name}")
|
|
|
|
def _defer_item(self, raw: str) -> None:
|
|
item = self._find_item(raw)
|
|
if not item:
|
|
return
|
|
if item.status not in ('browser_opened', 'pending', 'error'):
|
|
self._emit_notice(f"[cannot defer item in state: {item.status}]")
|
|
return
|
|
self._manager.skip_item(item.file_name)
|
|
self._emit_notice(f"[deferred] {item.file_name}")
|
|
self._status_dirty = True
|
|
|
|
def _confirm_start(self) -> bool:
|
|
self._output("")
|
|
for line in self._boxed_lines(
|
|
"Non-Premium Manual Download Flow",
|
|
[
|
|
"Jackify detected that manual Nexus downloads are required.",
|
|
"It will open Nexus pages in your browser, watch your download folder,",
|
|
"validate files automatically, and resume once everything is present.",
|
|
"",
|
|
"Key commands:",
|
|
"concurrency 1-5 Change simultaneous browser tabs",
|
|
"defer <index> Skip an item for now",
|
|
"retry Reopen deferred items",
|
|
"watch <path> Change monitored download folder",
|
|
],
|
|
):
|
|
self._output(line)
|
|
if not sys.stdin.isatty():
|
|
return True
|
|
self._output("Continue? [Y/n]: ")
|
|
try:
|
|
response = (sys.stdin.readline() or "").strip().lower()
|
|
except Exception:
|
|
return False
|
|
return response in ("", "y", "yes")
|
|
|
|
def _redraw_terminal(self, lines: list[str]) -> None:
|
|
if self._terminal is None:
|
|
return
|
|
terminal_width = self._terminal_columns()
|
|
if self._screen_lines:
|
|
self._terminal.write(f"\x1b[{self._screen_lines}F")
|
|
self._terminal.write("\x1b[J")
|
|
for line in lines:
|
|
self._terminal.write(line[: max(1, terminal_width - 1)] + "\n")
|
|
self._terminal.flush()
|
|
self._screen_lines = len(lines)
|
|
|
|
@staticmethod
|
|
def _truncate(value: str, width: int) -> str:
|
|
if len(value) <= width:
|
|
return value
|
|
if width <= 3:
|
|
return value[:width]
|
|
return value[: width - 3] + "..."
|
|
|
|
@staticmethod
|
|
def _format_table_header() -> str:
|
|
return "Idx State Size Mod File"
|
|
|
|
def _boxed_lines(self, title: str, rows: list[str], width: Optional[int] = None) -> list[str]:
|
|
width = width or max(60, min(100, self._terminal_columns() - 2))
|
|
inner_width = max(20, width - 4)
|
|
top = "+" + "-" * (width - 2) + "+"
|
|
rendered = [top, f"| {self._truncate(title, inner_width).ljust(inner_width)} |"]
|
|
if rows:
|
|
rendered.append("|" + "-" * (width - 2) + "|")
|
|
for row in rows:
|
|
rendered.append(f"| {self._truncate(row, inner_width).ljust(inner_width)} |")
|
|
rendered.append(top)
|
|
return rendered
|
|
|
|
def _terminal_columns(self) -> int:
|
|
return shutil.get_terminal_size(fallback=(100, 24)).columns
|
|
|
|
def _emit_notice(self, message: str) -> None:
|
|
if self._interactive_tty:
|
|
self._notices.append(message)
|
|
self._status_dirty = True
|
|
return
|
|
self._output(message)
|
|
|
|
def run_cli_manual_download_phase(
|
|
events: list[dict],
|
|
loop_iteration: int,
|
|
download_dir: Path,
|
|
stdin_write: Callable[[str], bool],
|
|
output_callback: Optional[Callable[[str], None]] = None,
|
|
concurrent_limit: int = 2,
|
|
) -> bool:
|
|
"""
|
|
Entry point called from modlist_service_installation when the engine emits
|
|
a manual_download_list_complete event. Blocks until done.
|
|
"""
|
|
flow = CliManualDownloadFlow(
|
|
items=events,
|
|
loop_iteration=loop_iteration,
|
|
download_dir=download_dir,
|
|
stdin_write=stdin_write,
|
|
output_callback=output_callback,
|
|
concurrent_limit=concurrent_limit,
|
|
)
|
|
return flow.run()
|