#!/usr/bin/env python3 # -*- coding: utf-8 -*- from __future__ import annotations import argparse import fnmatch import glob import json import os import re import shlex import shutil import subprocess import sys import tempfile import unicodedata import urllib.request from pathlib import Path from typing import Iterable, Optional GIB = 1024 ** 3 MIB = 1024 ** 2 DEFAULT_TITLE_MAP_URL = ( "https://gist.githubusercontent.com/AdrianCassar/" "c0d05a14608168259232b3ed8c77f28c/raw/" "482e1a7a303cceaf747a4acd765dd950b056019a/Title%2520IDs.json" ) TITLE_ID_RE = re.compile(r"^[0-9a-fA-F]{8}$") class HelpFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter): pass def log(msg: str) -> None: print(msg, flush=True) def warn(msg: str) -> None: print(msg, file=sys.stderr, flush=True) def sanitize_title_dir_name(title: str, fallback: str) -> str: """ Zamienia tytuł gry na bezpieczną nazwę katalogu. Zostają tylko znaki ASCII: A-Z, a-z, 0-9, _ oraz -. Spacje i inne niedozwolone znaki są zamieniane na pojedyncze _. """ normalized = unicodedata.normalize("NFKD", title) ascii_text = normalized.encode("ascii", "ignore").decode("ascii") safe = re.sub(r"[^A-Za-z0-9_-]+", "_", ascii_text) safe = re.sub(r"_+", "_", safe).strip("_-") return safe or fallback.upper() def parse_title_map_obj(obj: object) -> dict[str, str]: if not isinstance(obj, list): raise ValueError("title-map musi być listą obiektów z polami titleid i title") out: dict[str, str] = {} for item in obj: if not isinstance(item, dict): continue title_id = str(item.get("titleid", "")).strip().upper() title = str(item.get("title", "")).strip() if TITLE_ID_RE.fullmatch(title_id) and title: out[title_id] = title if not out: raise ValueError("title-map nie zawiera poprawnych wpisów titleid/title") return out def load_title_map_file(path: Path) -> dict[str, str]: with path.expanduser().open("r", encoding="utf-8") as fh: return parse_title_map_obj(json.load(fh)) def load_title_map_url(url: str, timeout: float) -> dict[str, str]: req = urllib.request.Request(url, headers={"User-Agent": "iso2god_batch/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as response: charset = response.headers.get_content_charset() or "utf-8" text = response.read().decode(charset, errors="replace") return parse_title_map_obj(json.loads(text)) def load_title_map(args: argparse.Namespace) -> dict[str, str]: title_map: dict[str, str] = {} if args.title_map_url: loaded = load_title_map_url(args.title_map_url, args.title_map_timeout) title_map.update(loaded) log(f"[TITLE] pobrano title-map z URL: {len(loaded)} wpisów") if args.title_map_file: path = Path(args.title_map_file).expanduser() loaded = load_title_map_file(path) title_map.update(loaded) log(f"[TITLE] wczytano title-map z pliku: {len(loaded)} wpisów") return title_map def unique_title_target(dest_dir: Path, base_name: str, title_id: str) -> Path: target = dest_dir / base_name if not target.exists(): return target title_id_l = title_id.upper() target = dest_dir / f"{base_name}_{title_id_l}" if not target.exists(): return target idx = 2 while True: target = dest_dir / f"{base_name}_{title_id_l}_{idx}" if not target.exists(): return target idx += 1 def rename_converted_title_dirs(dest_dir: Path, title_map: dict[str, str]) -> int: """ iso2god zwykle tworzy katalog TITLEID, np. 4156004D. Jeśli znamy TITLEID z mapy, zmieniamy nazwę katalogu na oczyszczony tytuł gry. """ if not title_map or not dest_dir.exists(): return 0 renamed = 0 for child in sorted(dest_dir.iterdir(), key=natural_key): if not child.is_dir() or not TITLE_ID_RE.fullmatch(child.name): continue title_id = child.name.upper() title = title_map.get(title_id) if not title: continue base_name = sanitize_title_dir_name(title, fallback=title_id.lower()) if child.name == base_name: continue target = unique_title_target(dest_dir, base_name, title_id) child.rename(target) log(f"[TITLE] {child.name} -> {target.name} ({title})") renamed += 1 return renamed def natural_key(path: Path): return [int(x) if x.isdigit() else x.lower() for x in re.split(r"(\d+)", str(path))] def format_bytes(value: int) -> str: value_f = float(value) for unit in ("B", "KiB", "MiB", "GiB", "TiB"): if value_f < 1024.0 or unit == "TiB": return f"{value_f:.2f} {unit}" value_f /= 1024.0 return f"{value} B" def bytes_from_gb(value: float) -> int: return int(max(value, 0.0) * GIB) def free_bytes(path: Path) -> int: probe = path if path.exists() else path.parent while not probe.exists() and probe != probe.parent: probe = probe.parent return shutil.disk_usage(probe).free def is_iso(path: Path) -> bool: return path.is_file() and path.name.lower().endswith(".iso") def archive_kind(path: Path) -> Optional[str]: """ Zwraca '7z' albo 'rar' tylko dla partu, od którego należy zacząć. Kolejne party są pomijane, żeby nie przerabiać tej samej gry wiele razy. """ if not path.is_file(): return None name = path.name.lower() # 7z wieloczęściowy: gra.7z.001, gra.7z.002... m = re.search(r"\.7z\.0*(\d+)$", name) if m: return "7z" if int(m.group(1)) == 1 else None if name.endswith(".7z"): return "7z" # RAR wieloczęściowy: gra.part1.rar, gra.part01.rar... m = re.search(r"\.part0*(\d+)\.rar$", name) if m: return "rar" if int(m.group(1)) == 1 else None # Stary RAR: gra.rar + gra.r00/gra.r01... if re.search(r"\.r\d+$", name): return None if name.endswith(".rar"): return "rar" return None def source_has_glob(value: str) -> bool: return any(ch in value for ch in "*?[") def unique_paths(paths: Iterable[Path]) -> list[Path]: seen: set[str] = set() out: list[Path] = [] for path in paths: try: key = str(path.expanduser().resolve()) except OSError: key = str(path.expanduser().absolute()) if key in seen: continue seen.add(key) out.append(Path(key)) return out def expand_source_specs(source_specs: list[str]) -> list[Path]: """ Obsługuje: - katalog: /downloads - plik ISO/archiwum: /downloads/gra.iso - glob: /downloads/*X360* - wiele pozycji po -s, np. gdy shell rozwinie *X360* """ expanded: list[Path] = [] for raw in source_specs: spec = os.path.expanduser(raw) if source_has_glob(spec): found = [Path(p) for p in glob.glob(spec, recursive=True)] if not found: warn(f"[WARN] wzorzec źródła niczego nie znalazł: {raw}") expanded.extend(found) else: expanded.append(Path(spec)) return unique_paths(expanded) def candidate_for_file(path: Path) -> Optional[tuple[str, Path]]: if is_iso(path): return ("iso", path) kind = archive_kind(path) if kind: return (kind, path) return None def iter_candidates(source: Path, recursive: bool) -> Iterable[tuple[str, Path]]: if source.is_file(): candidate = candidate_for_file(source) if candidate: yield candidate return if not source.is_dir(): warn(f"[WARN] pomijam, nie istnieje albo nie jest plikiem/katalogiem: {source}") return files = source.rglob("*") if recursive else source.glob("*") for path in sorted(files, key=natural_key): candidate = candidate_for_file(path) if candidate: yield candidate def path_matches_pattern(path: Path, pattern: str) -> bool: """ Filtr działa po nazwie pliku i pełnej ścieżce. Bez globów traktuje pattern jako zwykłe słowo kluczowe, np. X360. Z globami działa np. *X360* albo *.iso. """ pattern_l = pattern.lower() texts = (path.name.lower(), str(path).lower()) for text in texts: if pattern_l in text: return True if fnmatch.fnmatch(text, pattern_l): return True return False def apply_path_filters( candidates: Iterable[tuple[str, Path]], include_patterns: list[str], exclude_patterns: list[str], ) -> list[tuple[str, Path]]: seen: set[str] = set() out: list[tuple[str, Path]] = [] for kind, path in candidates: key = str(path.resolve()) if key in seen: continue seen.add(key) if include_patterns and not any(path_matches_pattern(path, p) for p in include_patterns): continue if exclude_patterns and any(path_matches_pattern(path, p) for p in exclude_patterns): continue out.append((kind, path)) return sorted(out, key=lambda item: natural_key(item[1])) def which_first(names: list[str]) -> Optional[str]: for name in names: found = shutil.which(name) if found: return found return None def command_exists(token: str) -> bool: expanded = Path(token).expanduser() if os.path.sep in token or (os.path.altsep and os.path.altsep in token): return expanded.exists() return shutil.which(token) is not None def quote_cmd(cmd: list[str]) -> str: return " ".join(shlex.quote(str(x)) for x in cmd) def run_checked(cmd: list[str], cwd: Optional[Path] = None) -> None: log("+ " + quote_cmd(cmd)) proc = subprocess.run( [str(x) for x in cmd], cwd=str(cwd) if cwd else None, stdin=subprocess.DEVNULL, ) if proc.returncode != 0: raise RuntimeError(f"komenda zakończona kodem {proc.returncode}: {quote_cmd(cmd)}") def rm_tree_contents(path: Path) -> None: if path.exists(): shutil.rmtree(path) path.mkdir(parents=True, exist_ok=True) def extractor_commands(kind: str, archive: Path, out_dir: Path, password: Optional[str]) -> list[list[str]]: cmds: list[list[str]] = [] sevenz = which_first(["7z", "7zz", "7za"]) if sevenz: cmd = [sevenz, "x", "-y", f"-o{out_dir}"] if password: cmd.append(f"-p{password}") cmd.append(str(archive)) cmds.append(cmd) unar = shutil.which("unar") if unar: cmd = [unar, "-force-overwrite", "-o", str(out_dir)] if password: cmd += ["-password", password] cmd.append(str(archive)) cmds.append(cmd) if kind == "rar": unrar = shutil.which("unrar") if unrar: # Nie dodajemy -y: przy błędzie zapisu mogłoby to oznaczać ciągłe Retry. # stdin=DEVNULL w extract_archive sprawia, że prompt Retry/Abort nie blokuje skryptu. cmd = [unrar, "x", "-o+"] if password: cmd.append(f"-p{password}") cmd += [str(archive), str(out_dir) + os.sep] cmds.append(cmd) return cmds def parse_7z_slt_iso_size(output: str) -> Optional[int]: total = 0 current_path: Optional[str] = None current_size: Optional[int] = None def commit() -> None: nonlocal total, current_path, current_size if current_path and current_path.lower().endswith(".iso") and current_size is not None: total += current_size current_path = None current_size = None for raw_line in output.splitlines(): line = raw_line.strip() if not line: commit() continue if line.startswith("Path = "): commit() current_path = line[7:].strip() current_size = None continue if line.startswith("Size = "): value = line[7:].strip() if value.isdigit(): current_size = int(value) commit() return total if total > 0 else None def estimate_archive_iso_bytes_with_7z(archive: Path, password: Optional[str]) -> Optional[int]: sevenz = which_first(["7z", "7zz", "7za"]) if not sevenz: return None cmd = [sevenz, "l", "-slt", "-y"] if password: cmd.append(f"-p{password}") cmd.append(str(archive)) try: proc = subprocess.run( [str(x) for x in cmd], cwd=str(archive.parent), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, errors="replace", timeout=120, ) except Exception: return None if proc.returncode != 0: return None return parse_7z_slt_iso_size(proc.stdout) def estimate_archive_extract_bytes( archive: Path, password: Optional[str], fallback_gb: float, ) -> tuple[int, str]: estimated = estimate_archive_iso_bytes_with_7z(archive, password) if estimated is not None: return estimated, "listing 7z" return bytes_from_gb(fallback_gb), f"fallback {fallback_gb:g} GiB" def ensure_enough_work_space( work_parent: Path, archive: Path, password: Optional[str], min_free_gb: float, fallback_gb: float, no_space_check: bool, ) -> None: if no_space_check: return work_parent.mkdir(parents=True, exist_ok=True) needed, source = estimate_archive_extract_bytes(archive, password, fallback_gb) reserve = bytes_from_gb(min_free_gb) required = needed + reserve free = free_bytes(work_parent) log( f"[SPACE] work={work_parent} wolne={format_bytes(free)} " f"potrzeba~={format_bytes(needed)} zapas={format_bytes(reserve)} źródło={source}" ) if free < required: raise RuntimeError( f"za mało miejsca w katalogu roboczym: {work_parent}; " f"wolne {format_bytes(free)}, wymagane ok. {format_bytes(required)}. " f"Użyj --work-dir na ext4/xfs/btrfs z większą ilością miejsca." ) def extract_archive(kind: str, archive: Path, out_dir: Path, password: Optional[str]) -> None: cmds = extractor_commands(kind, archive, out_dir, password) if not cmds: raise RuntimeError("brak programu do rozpakowania: zainstaluj 7z/7zz/7za, unar albo unrar") last_code = None for idx, cmd in enumerate(cmds, start=1): rm_tree_contents(out_dir) log(f"[EXTRACT] {archive.name} -> {out_dir} (próba {idx}/{len(cmds)})") log("+ " + quote_cmd(cmd)) proc = subprocess.run( [str(x) for x in cmd], cwd=str(archive.parent), stdin=subprocess.DEVNULL, ) if proc.returncode == 0: return last_code = proc.returncode warn(f"[WARN] rozpakowanie nieudane, kod {proc.returncode}; próbuję następny backend") free = free_bytes(out_dir.parent) raise RuntimeError( f"nie udało się rozpakować archiwum, ostatni kod: {last_code}; " f"wolne w katalogu roboczym: {format_bytes(free)}. " f"Przy 'Write error' najczęściej brakuje miejsca albo filesystem nie obsługuje plików >4 GiB. " f"Ustaw np. --work-dir /home/user/iso_tmp" ) def find_isos(root: Path) -> list[Path]: return sorted( (p for p in root.rglob("*") if p.is_file() and p.name.lower().endswith(".iso")), key=natural_key, ) def build_iso2god_cmd( iso2god_cmd: list[str], iso_path: Path, dest_dir: Path, threads: Optional[int], trim: str, iso2god_dry_run: bool, extra_args: list[str], ) -> list[str]: cmd = list(iso2god_cmd) if iso2god_dry_run: cmd.append("--dry-run") # iso2god-rs domyślnie przycina z końca; dodajemy jawnie dla czytelności. if trim == "from-end": cmd.append("--trim") elif trim == "none": cmd.append("--trim=none") if threads and threads > 0: cmd += ["-j", str(threads)] cmd += extra_args cmd += [str(iso_path), str(dest_dir)] return cmd def convert_iso( iso_path: Path, dest_dir: Path, iso2god_cmd: list[str], threads: Optional[int], trim: str, iso2god_dry_run: bool, extra_args: list[str], title_map: dict[str, str], ) -> None: log(f"[ISO2GOD] {iso_path}") cmd = build_iso2god_cmd( iso2god_cmd=iso2god_cmd, iso_path=iso_path, dest_dir=dest_dir, threads=threads, trim=trim, iso2god_dry_run=iso2god_dry_run, extra_args=extra_args, ) run_checked(cmd) rename_converted_title_dirs(dest_dir, title_map) def process_archive( kind: str, archive: Path, dest_dir: Path, work_parent: Path, keep_temp: bool, password: Optional[str], iso2god_cmd: list[str], threads: Optional[int], trim: str, iso2god_dry_run: bool, extra_args: list[str], min_free_gb: float, assume_iso_size_gb: float, no_space_check: bool, title_map: dict[str, str], ) -> int: ensure_enough_work_space( work_parent=work_parent, archive=archive, password=password, min_free_gb=min_free_gb, fallback_gb=assume_iso_size_gb, no_space_check=no_space_check, ) tmp_dir = Path(tempfile.mkdtemp(prefix="iso2god_batch_", dir=str(work_parent))) extract_dir = tmp_dir / "extract" try: extract_archive(kind, archive, extract_dir, password) isos = find_isos(extract_dir) if not isos: raise RuntimeError("w archiwum nie znaleziono pliku .iso") for iso in isos: convert_iso( iso_path=iso, dest_dir=dest_dir, iso2god_cmd=iso2god_cmd, threads=threads, trim=trim, iso2god_dry_run=iso2god_dry_run, extra_args=extra_args, title_map=title_map, ) return len(isos) finally: if keep_temp: log(f"[TEMP] zostawiam: {tmp_dir}") else: shutil.rmtree(tmp_dir, ignore_errors=True) def resolve_work_parent(args: argparse.Namespace, dest: Path) -> Path: if args.work_dir: return Path(args.work_dir).expanduser().resolve() env_work_dir = os.environ.get("ISO2GOD_WORK_DIR") if env_work_dir: return Path(env_work_dir).expanduser().resolve() # Nie używamy /tmp domyślnie. ISO z X360 zwykle potrzebuje 8-9 GiB. # /tmp bywa tmpfs-em w RAM-ie i wtedy unrar pada przy 70-90%. return (dest.parent / ".iso2god_batch_work").resolve() def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser( description="Batch ISO/7z/RAR(part) -> ISO2GOD dla Xbox 360.", formatter_class=HelpFormatter, epilog="""Przykłady: Prosto: %(prog)s -s /katalog/zrodlowy -d /home/dysk/Games Glob w źródle: %(prog)s -s "/downloads/*X360*" -d /home/dysk/Games %(prog)s -s *X360* -d /home/dysk/Games Include / filtrowanie po słowach w pełnej ścieżce: %(prog)s -s /downloads -d /home/dysk/Games --include X360 %(prog)s -s /downloads -d /home/dysk/Games --include XBOX360 --include Xbox_360 %(prog)s -s /downloads -d /home/dysk/Games --include Batman Exclude / pomijanie katalogów i plików: %(prog)s -s /downloads -d /home/dysk/Games --exclude DLC --exclude XBLA --exclude PS3 %(prog)s -s /downloads -d /home/dysk/Games --include X360 --exclude "*DLC*" --exclude "*Update*" %(prog)s -s /downloads -d /home/dysk/Games --include "*COMPLEX*" --exclude Sample Fix na błąd /tmp, Write error, sshfs albo za mało miejsca: %(prog)s -s /home/mg/sshfs -d /home/dysk/Games --work-dir /home/mg/iso_tmp %(prog)s -s /home/mg/sshfs -d /home/dysk/Games --work-dir /home/dysk/.iso2god_tmp --min-free-gb 5 Samo wykrywanie bez konwersji: %(prog)s -s /downloads -d /home/dysk/Games --include X360 --list-only Kilka źródeł naraz: %(prog)s -s /iso /rar "/mnt/seedbox/*X360*" -d /home/dysk/Games --exclude DLC Nazwy katalogów z titleid -> title: %(prog)s -s /downloads -d /home/dysk/Games --include X360 --title-map-url %(prog)s -s /downloads -d /home/dysk/Games --title-map-file /home/mg/TitleIDs.json Uwagi: * Katalog roboczy służy do pełnego, tymczasowego rozpakowania ISO. * Domyślnie work-dir jest obok katalogu docelowego: DEST_PARENT/.iso2god_batch_work, nie /tmp. * Możesz też ustawić zmienną: ISO2GOD_WORK_DIR=/home/mg/iso_tmp. * Dla RAR partów skrypt startuje tylko .rar / .part1.rar; .r00/.r01/.part2.rar są pomijane. * Jeśli dysk docelowy jest FAT32 albo nie obsługuje plików >4 GiB, daj --work-dir na ext4/xfs/btrfs. * --title-map-url/--title-map-file zmienia katalog TITLEID na tytuł gry oczyszczony do A-Z, a-z, 0-9, _ oraz -. """, ) p.add_argument( "-s", "--source", required=True, nargs="+", help="jeden lub więcej katalogów/plików/wzorców glob z ISO/7z/RAR", ) p.add_argument("-d", "--dest", required=True, help="katalog docelowy dla plików GOD") p.add_argument( "--include", action="append", default=[], help="bierz tylko ścieżki pasujące do słowa/wzorca, np. X360 albo '*X360*'; można podać wiele razy", ) p.add_argument( "--exclude", action="append", default=[], help="pomiń ścieżki pasujące do słowa/wzorca, np. DLC, PS3 albo '*Sample*'; można podać wiele razy", ) p.add_argument( "--iso2god-cmd", default="iso2god", help="komenda ISO2GOD, np. 'iso2god' albo '/opt/iso2god/iso2god'", ) p.add_argument("-j", "--threads", type=int, default=0, help="liczba wątków dla iso2god; 0 = domyślnie") p.add_argument("--trim", choices=["from-end", "none"], default="from-end", help="tryb przycinania ISO") p.add_argument( "--work-dir", "--tmp-dir", "--temp-dir", dest="work_dir", default=None, help="katalog roboczy na tymczasowo rozpakowane ISO; domyślnie obok --dest albo z ISO2GOD_WORK_DIR", ) p.add_argument("--archive-password", default=None, help="hasło do archiwów, gdy potrzebne") p.add_argument("--keep-temp", action="store_true", help="nie kasuj katalogów tymczasowych") p.add_argument("--no-recursive", action="store_true", help="skanuj tylko główny katalog źródłowy") p.add_argument("--list-only", action="store_true", help="tylko pokaż wykryte pliki, bez pracy") p.add_argument("--iso2god-dry-run", action="store_true", help="przekaż --dry-run do iso2god") p.add_argument("--stop-on-error", action="store_true", help="przerwij po pierwszym błędzie") p.add_argument( "--min-free-gb", type=float, default=2.0, help="dodatkowy zapas wolnego miejsca wymagany w work-dir przed rozpakowaniem", ) p.add_argument( "--assume-iso-size-gb", type=float, default=9.0, help="awaryjny rozmiar ISO, gdy nie uda się odczytać listy archiwum", ) p.add_argument( "--no-space-check", action="store_true", help="wyłącz sprawdzanie wolnego miejsca przed rozpakowaniem archiwum", ) p.add_argument( "--title-map-url", nargs="?", const=DEFAULT_TITLE_MAP_URL, default=None, help="URL JSON-a titleid -> title; bez wartości używa wbudowanego URL-a", ) p.add_argument( "--title-map-file", "--title-map", dest="title_map_file", default=None, help="lokalny JSON titleid -> title; nadpisuje wpisy z --title-map-url", ) p.add_argument( "--title-map-timeout", type=float, default=20.0, help="timeout pobierania --title-map-url w sekundach", ) p.add_argument( "--extra-iso2god-arg", action="append", default=[], help="dodatkowy argument dla iso2god; można podać wiele razy", ) return p.parse_args() def main() -> int: args = parse_args() sources = expand_source_specs(args.source) dest = Path(args.dest).expanduser().resolve() recursive = not args.no_recursive iso2god_cmd = shlex.split(args.iso2god_cmd) if not iso2god_cmd: warn("[ERR] pusta komenda --iso2god-cmd") return 2 if not sources: warn("[ERR] żadne źródło z -s/--source nie pasuje do pliku ani katalogu") return 2 raw_candidates: list[tuple[str, Path]] = [] for source in sources: raw_candidates.extend(iter_candidates(source, recursive=recursive)) candidates = apply_path_filters( raw_candidates, include_patterns=args.include, exclude_patterns=args.exclude, ) if not candidates: log("[INFO] brak pasujących plików .iso, .7z albo pierwszych partów .rar") return 0 log("[INFO] źródła:") for source in sources: log(f" {source}") log(f"[INFO] cel: {dest}") if args.include: log(f"[INFO] include: {', '.join(args.include)}") if args.exclude: log(f"[INFO] exclude: {', '.join(args.exclude)}") log(f"[INFO] znaleziono: {len(candidates)} pozycji") if args.list_only: for kind, path in candidates: print(f"{kind:>3} {path}") return 0 dest.mkdir(parents=True, exist_ok=True) if not command_exists(iso2god_cmd[0]): warn(f"[ERR] nie znaleziono ISO2GOD: {iso2god_cmd[0]}") warn(" Podaj np. --iso2god-cmd /pełna/ścieżka/do/iso2god") return 2 try: title_map = load_title_map(args) except Exception as exc: warn(f"[ERR] nie udało się wczytać title-map: {exc}") return 2 work_parent = resolve_work_parent(args, dest) work_parent.mkdir(parents=True, exist_ok=True) log(f"[INFO] work-dir: {work_parent}") archive_kinds = sorted({kind for kind, _ in candidates if kind in {"7z", "rar"}}) for kind in archive_kinds: dummy = Path(f"dummy.{kind}") if not extractor_commands(kind, dummy, work_parent / "dummy", args.archive_password): warn(f"[ERR] brak programu do archiwów {kind}: zainstaluj 7z/7zz/7za" + (", unar albo unrar" if kind == "rar" else " albo unar")) return 2 converted = 0 failed: list[tuple[Path, str]] = [] for idx, (kind, path) in enumerate(candidates, start=1): log("") log(f"[{idx}/{len(candidates)}] {kind.upper()} {path}") try: if kind == "iso": convert_iso( iso_path=path, dest_dir=dest, iso2god_cmd=iso2god_cmd, threads=args.threads, trim=args.trim, iso2god_dry_run=args.iso2god_dry_run, extra_args=args.extra_iso2god_arg, title_map=title_map, ) converted += 1 else: converted += process_archive( kind=kind, archive=path, dest_dir=dest, work_parent=work_parent, keep_temp=args.keep_temp, password=args.archive_password, iso2god_cmd=iso2god_cmd, threads=args.threads, trim=args.trim, iso2god_dry_run=args.iso2god_dry_run, extra_args=args.extra_iso2god_arg, min_free_gb=args.min_free_gb, assume_iso_size_gb=args.assume_iso_size_gb, no_space_check=args.no_space_check, title_map=title_map, ) except KeyboardInterrupt: warn("\n[ERR] przerwano przez użytkownika") return 130 except Exception as exc: failed.append((path, str(exc))) warn(f"[ERR] {path}: {exc}") if args.stop_on_error: break log("") log(f"[DONE] skonwertowanych ISO: {converted}") if failed: warn(f"[FAIL] błędy: {len(failed)}") for path, msg in failed: warn(f" - {path}: {msg}") return 1 return 0 if __name__ == "__main__": raise SystemExit(main())