mirror of
https://github.com/Nazky/Auto-Backpork.git
synced 2026-10-22 00:22:25 -05:00
- Add a GUI (beta for now, expect some bugs) - Add archive support (same beta, bugs, etc.) - Update README - Add batch and bash script to use the GUI - More i forgot...
2271 lines
115 KiB
Python
2271 lines
115 KiB
Python
import os
|
|
import re
|
|
import json
|
|
import io
|
|
import time
|
|
import webbrowser
|
|
import platform
|
|
import subprocess
|
|
import zipfile
|
|
import tempfile
|
|
import threading
|
|
import base64
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
import tkinter as tk
|
|
from tkinter import messagebox, simpledialog, scrolledtext
|
|
|
|
import py7zr
|
|
import rarfile
|
|
import customtkinter as ctk
|
|
from PIL import Image
|
|
import tkinter.filedialog as filedialog
|
|
|
|
# =====================================================================
|
|
# PYINSTALLER CONSOLE FIX (isatty error)
|
|
# =====================================================================
|
|
class DummyStream:
|
|
"""Fake stream for PyInstaller --noconsole mode to prevent isatty() crashes."""
|
|
def write(self, text): pass
|
|
def flush(self): pass
|
|
def isatty(self): return False
|
|
def fileno(self): return -1
|
|
|
|
# If running as a compiled GUI exe without a console, replace None streams
|
|
if getattr(sys, 'frozen', False) and (sys.stdout is None or sys.stderr is None):
|
|
sys.stdout = DummyStream()
|
|
sys.stderr = DummyStream()
|
|
|
|
# =====================================================================
|
|
# MACOS ENVIRONMENT FIX
|
|
# =====================================================================
|
|
def setup_macos_environment():
|
|
"""
|
|
Fix PATH environment for macOS GUI apps.
|
|
GUI apps on macOS do not inherit shell paths (like Homebrew /opt/homebrew/bin).
|
|
We must manually add them so shutil.which() can find brew, unrar, 7z, etc.
|
|
"""
|
|
if platform.system().lower() != "darwin":
|
|
return
|
|
|
|
# Common paths for Homebrew and standard binaries
|
|
extra_paths = [
|
|
"/opt/homebrew/bin", # Apple Silicon Homebrew
|
|
"/opt/homebrew/sbin",
|
|
"/usr/local/bin", # Intel Homebrew / Standard
|
|
"/usr/local/sbin",
|
|
"/usr/bin",
|
|
"/bin",
|
|
"/usr/sbin",
|
|
"/sbin",
|
|
"/opt/local/bin", # MacPorts
|
|
]
|
|
|
|
current_path = os.environ.get("PATH", "")
|
|
paths = current_path.split(":")
|
|
|
|
changed = False
|
|
for p in extra_paths:
|
|
if p not in paths:
|
|
paths.append(p)
|
|
changed = True
|
|
|
|
if changed:
|
|
os.environ["PATH"] = ":".join(paths)
|
|
# print(f"[MACOS FIX] Updated PATH: {os.environ['PATH']}")
|
|
|
|
# Run this IMMEDIATELY before any other checks
|
|
setup_macos_environment()
|
|
|
|
def get_platform():
|
|
"""Get normalized platform name: 'windows', 'macos', or 'linux'."""
|
|
system = platform.system().lower()
|
|
if system == "windows":
|
|
return "windows"
|
|
elif system == "darwin":
|
|
return "macos"
|
|
else:
|
|
return "linux"
|
|
|
|
|
|
# Configure rarfile for cross-platform unrar
|
|
def configure_rarfile():
|
|
"""Configure rarfile to find unrar binary on different platforms."""
|
|
current_platform = get_platform()
|
|
if current_platform == "windows":
|
|
# Windows: Find UnRAR.exe from WinRAR installation
|
|
|
|
# Method 1: Check if unrar is in PATH
|
|
unrar_path = shutil.which('unrar')
|
|
if unrar_path:
|
|
rarfile.UNRAR_TOOL = unrar_path
|
|
return
|
|
|
|
# Method 2: Check common WinRAR installation paths
|
|
possible_paths = [
|
|
r"C:\Program Files\WinRAR\UnRAR.exe",
|
|
r"C:\Program Files (x86)\WinRAR\UnRAR.exe",
|
|
]
|
|
|
|
for path in possible_paths:
|
|
if os.path.exists(path):
|
|
rarfile.UNRAR_TOOL = path
|
|
# Add WinRAR directory to PATH so subprocess can find it
|
|
winrar_dir = os.path.dirname(path)
|
|
current_path = os.environ.get('PATH', '')
|
|
if winrar_dir.lower() not in current_path.lower():
|
|
os.environ['PATH'] = current_path + ';' + winrar_dir
|
|
return
|
|
|
|
# Method 3: Check registry for WinRAR installation path
|
|
try:
|
|
import winreg
|
|
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\WinRAR archiver")
|
|
install_location = winreg.QueryValueEx(key, "InstallLocation")[0]
|
|
winreg.CloseKey(key)
|
|
|
|
if install_location:
|
|
unrar_exe = os.path.join(install_location, "UnRAR.exe")
|
|
if os.path.exists(unrar_exe):
|
|
rarfile.UNRAR_TOOL = unrar_exe
|
|
os.environ['PATH'] = os.environ.get('PATH', '') + ';' + install_location
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
print("[RARFILE] WARNING: Could not find UnRAR on Windows!")
|
|
|
|
elif current_platform == "macos":
|
|
# Thanks to setup_macos_environment(), shutil.which should now work
|
|
if shutil.which('unrar'):
|
|
rarfile.UNRAR_TOOL = shutil.which('unrar')
|
|
return
|
|
if shutil.which('rar'):
|
|
rarfile.UNRAR_TOOL = shutil.which('rar')
|
|
return
|
|
|
|
# Fallback hardcoded checks just in case
|
|
possible_paths = [
|
|
"/opt/homebrew/bin/unrar",
|
|
"/usr/local/bin/unrar",
|
|
"/usr/bin/unrar",
|
|
]
|
|
for path in possible_paths:
|
|
if os.path.exists(path):
|
|
rarfile.UNRAR_TOOL = path
|
|
break
|
|
else: # Linux
|
|
possible_paths = [
|
|
"/usr/bin/unrar",
|
|
"/usr/local/bin/unrar",
|
|
"/usr/bin/unrar-free",
|
|
"/snap/bin/unrar",
|
|
]
|
|
for path in possible_paths:
|
|
if os.path.exists(path):
|
|
rarfile.UNRAR_TOOL = path
|
|
break
|
|
|
|
configure_rarfile()
|
|
|
|
|
|
# =====================================================================
|
|
# DEPENDENCY CHECKER & INSTALLER (Runs before GUI imports)
|
|
# =====================================================================
|
|
def check_and_install_dependencies():
|
|
required_packages = {
|
|
'customtkinter': 'customtkinter',
|
|
'PIL': 'Pillow',
|
|
'py7zr': 'py7zr',
|
|
'rarfile': 'rarfile'
|
|
}
|
|
|
|
missing_packages = []
|
|
for import_name, pip_name in required_packages.items():
|
|
try:
|
|
__import__(import_name)
|
|
except ImportError:
|
|
missing_packages.append(pip_name)
|
|
|
|
missing_system_tools = []
|
|
|
|
if sys.platform == "win32":
|
|
found_7z = shutil.which('7z') or any(Path(p).exists() for p in [r"C:\Program Files\7-Zip\7z.exe", r"C:\Program Files (x86)\7-Zip\7z.exe"])
|
|
if not found_7z: missing_system_tools.append('7z')
|
|
|
|
found_unrar = shutil.which('unrar') or any(Path(p).exists() for p in [r"C:\Program Files\WinRAR\UnRAR.exe", r"C:\Program Files (x86)\WinRAR\UnRAR.exe"])
|
|
if not found_unrar: missing_system_tools.append('unrar')
|
|
else:
|
|
if not shutil.which('7z'): missing_system_tools.append('7z')
|
|
if not shutil.which('unrar'): missing_system_tools.append('unrar')
|
|
|
|
# =====================================================================
|
|
# CRITICAL FIX: DETECT EXECUTABLE MODE
|
|
# =====================================================================
|
|
|
|
# If running as compiled executable (frozen), we CANNOT use pip install.
|
|
# Attempting to run sys.executable would launch the app recursively (Fork Bomb).
|
|
if getattr(sys, 'frozen', False):
|
|
if missing_packages:
|
|
# If python packages are missing in an exe, the build is broken.
|
|
# Show error and exit immediately.
|
|
root = tk.Tk()
|
|
root.withdraw()
|
|
missing_list = "\n".join(missing_packages)
|
|
messagebox.showerror(
|
|
"Critical Error",
|
|
f"The application is missing required internal libraries:\n\n{missing_list}\n\n"
|
|
"This executable is corrupted. Please redownload or rebuild it."
|
|
)
|
|
sys.exit(1)
|
|
|
|
if missing_system_tools:
|
|
# We can warn, but we can't easily install system tools silently in app mode
|
|
# without a package manager UI. We will just warn the user.
|
|
root = tk.Tk()
|
|
root.withdraw()
|
|
messagebox.showwarning(
|
|
"Missing Tools",
|
|
f"Missing system tools: {', '.join(missing_system_tools)}\n\n"
|
|
"Some features (extraction) may not work.\n"
|
|
"Please install them manually (e.g., 7-Zip, WinRAR/UnRAR)."
|
|
)
|
|
# If we reach here, critical checks passed.
|
|
return True
|
|
|
|
# =====================================================================
|
|
# SCRIPT MODE (Development) - Allow Auto-Install
|
|
# =====================================================================
|
|
|
|
if not missing_packages and not missing_system_tools:
|
|
return True
|
|
|
|
setup_win = tk.Tk()
|
|
setup_win.title("Auto-Backpork - Dependency Setup")
|
|
setup_win.geometry("650x500")
|
|
setup_win.configure(bg="#1a1a1a")
|
|
|
|
tk.Label(setup_win, text="Setting up dependencies...", font=("Arial", 16, "bold"), bg="#1a1a1a", fg="white").pack(pady=10)
|
|
log = scrolledtext.ScrolledText(setup_win, width=75, height=22, bg="#2b2b2b", fg="#00ff00", font=("Consolas", 10), state='normal')
|
|
log.pack(padx=10, pady=5, fill="both", expand=True)
|
|
|
|
def append_log(msg):
|
|
log.insert(tk.END, msg + "\n")
|
|
log.see(tk.END)
|
|
setup_win.update()
|
|
|
|
def install_pip(pkg_name):
|
|
append_log(f"[PIP] Installing {pkg_name}...")
|
|
try:
|
|
subprocess.check_call([sys.executable, "-m", "pip", "install", pkg_name, "--quiet", "--disable-pip-version-check"])
|
|
append_log(f"[OK] {pkg_name} installed successfully.")
|
|
return True
|
|
except subprocess.CalledProcessError:
|
|
append_log(f"[ERROR] Failed to install {pkg_name}.")
|
|
return False
|
|
|
|
def install_linux_tool(tool_name, package_managers):
|
|
for pm_cmd, install_cmd, packages in package_managers:
|
|
if shutil.which(pm_cmd):
|
|
append_log(f"[SYSTEM] Found '{pm_cmd}'. Attempting to install {tool_name} ({packages[0]})...")
|
|
try:
|
|
if pm_cmd not in ['nix-env', 'flatpak']:
|
|
if os.geteuid() != 0: cmd = ['sudo'] + install_cmd + packages
|
|
else: cmd = install_cmd + packages
|
|
else: cmd = install_cmd + packages
|
|
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
append_log(f"[OK] {tool_name} installed successfully via {pm_cmd}.")
|
|
return True
|
|
except subprocess.CalledProcessError: append_log(f"[WARN] Installation failed via {pm_cmd}.")
|
|
except FileNotFoundError: append_log(f"[WARN] '{pm_cmd.split()[0]}' command not found.")
|
|
return False
|
|
|
|
def install_system_tools():
|
|
current_os = sys.platform
|
|
tools_to_install = list(missing_system_tools)
|
|
|
|
if '7z' in tools_to_install:
|
|
append_log("\n[SYSTEM] '7z' binary not found.")
|
|
installed = False
|
|
if current_os == "win32":
|
|
if shutil.which("winget"):
|
|
try:
|
|
subprocess.run(["winget", "install", "--id", "7zip.7zip", "--accept-source-agreements", "--accept-package-agreements", "--silent"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
append_log("[OK] 7-Zip installed."); tools_to_install.remove('7z'); installed = True
|
|
except Exception: append_log("[WARN] Winget failed.")
|
|
elif current_os == "darwin":
|
|
brew_path = shutil.which("brew") or "/opt/homebrew/bin/brew"
|
|
if os.path.exists(brew_path):
|
|
try:
|
|
append_log(f"[SYSTEM] Found Homebrew at {brew_path}. Installing p7zip...")
|
|
subprocess.run([brew_path, "install", "p7zip"], check=True)
|
|
append_log("[OK] p7zip installed."); tools_to_install.remove('7z'); installed = True
|
|
except Exception as e: append_log(f"[ERROR] Homebrew failed: {e}")
|
|
else:
|
|
linux_pm = [('apt', ['apt', 'install', '-y'], ['p7zip-full']), ('dnf', ['dnf', 'install', '-y'], ['p7zip', 'p7zip-plugins']), ('pacman', ['pacman', '-S', '--noconfirm'], ['p7zip']), ('zypper', ['zypper', '--non-interactive', 'install'], ['p7zip-full']), ('apk', ['apk', 'add'], ['p7zip'])]
|
|
if install_linux_tool('7z', linux_pm): tools_to_install.remove('7z'); installed = True
|
|
if not installed: append_log("[WARN] Could not auto-install 7z.")
|
|
|
|
if 'unrar' in tools_to_install:
|
|
append_log("\n[SYSTEM] 'unrar' binary not found.")
|
|
installed = False
|
|
if current_os == "win32":
|
|
if any(Path(p).exists() for p in [r"C:\Program Files\WinRAR\UnRAR.exe"]):
|
|
append_log("[OK] WinRAR found in C:\\Program Files\\WinRAR."); tools_to_install.remove('unrar'); installed = True
|
|
elif shutil.which("winget"):
|
|
try:
|
|
subprocess.run(["winget", "install", "--id", "RARLab.WinRAR", "--accept-source-agreements", "--accept-package-agreements", "--silent"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
append_log("[OK] WinRAR installed."); tools_to_install.remove('unrar'); installed = True
|
|
except Exception: pass
|
|
elif current_os == "darwin":
|
|
brew_path = shutil.which("brew") or "/opt/homebrew/bin/brew"
|
|
if os.path.exists(brew_path):
|
|
try:
|
|
subprocess.run([brew_path, "install", "unrar"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
append_log("[OK] unrar installed."); tools_to_install.remove('unrar'); installed = True
|
|
except Exception:
|
|
append_log("[WARN] Failed to install 'unrar'. Trying 'rar' as fallback...")
|
|
try:
|
|
subprocess.run([brew_path, "install", "rar"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
append_log("[OK] 'rar' installed as fallback."); tools_to_install.remove('unrar'); installed = True
|
|
except Exception: append_log("[ERROR] Both 'unrar' and 'rar' failed via Homebrew.")
|
|
else:
|
|
linux_pm = [('apt', ['apt', 'install', '-y'], ['unrar']), ('dnf', ['dnf', 'install', '-y'], ['unrar']), ('pacman', ['pacman', '-S', '--noconfirm'], ['unrar']), ('zypper', ['zypper', '--non-interactive', 'install'], ['unrar']), ('apk', ['apk', 'add'], ['unrar'])]
|
|
if install_linux_tool('unrar', linux_pm): tools_to_install.remove('unrar'); installed = True
|
|
if not installed: append_log("[ERROR] Could not auto-install unrar. .rar extraction will FAIL.")
|
|
|
|
setup_success = True
|
|
def _run_setup():
|
|
nonlocal setup_success
|
|
setup_success = True
|
|
for pkg in missing_packages:
|
|
if not install_pip(pkg): setup_success = False
|
|
if missing_system_tools: install_system_tools()
|
|
|
|
append_log("\n--------------------------------------------------")
|
|
if setup_success and not missing_system_tools:
|
|
append_log("Setup complete! Launching GUI..."); setup_win.after(1500, setup_win.destroy)
|
|
elif setup_success:
|
|
append_log("Setup partially complete. Launching GUI..."); setup_win.after(4000, setup_win.destroy)
|
|
else:
|
|
append_log("CRITICAL ERRORS. Closing in 10s..."); setup_win.after(10000, setup_win.destroy); sys.exit(1)
|
|
|
|
setup_win.after(100, _run_setup)
|
|
setup_win.mainloop()
|
|
return setup_success
|
|
|
|
if __name__ == "__main__":
|
|
if not check_and_install_dependencies(): sys.exit(1)
|
|
|
|
# =====================================================================
|
|
# MAIN APP CLASS
|
|
# =====================================================================
|
|
class App(ctk.CTk):
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
self.platform = get_platform()
|
|
|
|
# =====================================================================
|
|
# ROBUST PATH HANDLING FOR EXECUTABLE & APPIMAGE
|
|
# =====================================================================
|
|
|
|
if getattr(sys, 'frozen', False):
|
|
# Running as compiled executable (PyInstaller)
|
|
bundle_path = Path(sys._MEIPASS)
|
|
|
|
# Determine where to save user data (Config, Backport)
|
|
# We want this to be WRITABLE.
|
|
|
|
# 1. Check for Linux AppImage specifically
|
|
# The APPIMAGE environment variable holds the path to the actual .AppImage file
|
|
appimage_env = os.getenv('APPIMAGE')
|
|
if self.platform == "linux" and appimage_env:
|
|
# Data should be stored NEXT to the AppImage file
|
|
application_path = Path(appimage_env).parent.resolve()
|
|
|
|
# 2. Check for macOS .app bundle
|
|
elif self.platform == "macos":
|
|
# Data should be next to the .app bundle
|
|
application_path = Path(sys.executable).parent.parent.parent.parent
|
|
|
|
# 3. Standard Windows / Linux Portable Folder
|
|
else:
|
|
# Data is next to the executable
|
|
application_path = Path(sys.executable).parent.resolve()
|
|
else:
|
|
# Running as script
|
|
bundle_path = Path(__file__).parent.resolve()
|
|
application_path = bundle_path
|
|
|
|
self.project_root = application_path
|
|
|
|
# 2. Setup Working Directories (Config, Backport)
|
|
# These should be OUTSIDE the bundle (next to the exe/app) so they persist.
|
|
self.config_base = self.project_root / "config"
|
|
self.backport_dir = self.project_root / "Backport"
|
|
|
|
self.title("Auto-Backpork GUI")
|
|
self.geometry("1280x740")
|
|
|
|
self.config_dir = self.config_base / "App"
|
|
self.settings_file = self.config_dir / "settings.json"
|
|
self.cache_file = self.config_dir / "games.json"
|
|
|
|
# Create folders with permission fallback
|
|
try:
|
|
self.backport_dir.mkdir(parents=True, exist_ok=True)
|
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
except (PermissionError, OSError) as e:
|
|
# Fallback: If we can't write to the executable folder (Read-only AppImage or Permissions)
|
|
# fall back to the user's home documents folder.
|
|
print(f"Access denied at {self.project_root}: {e}")
|
|
home = Path.home()
|
|
self.project_root = home / "Auto-Backpork Data"
|
|
self.backport_dir = self.project_root / "Backport"
|
|
self.config_base = self.project_root / "config"
|
|
self.config_dir = self.config_base / "App"
|
|
|
|
self.backport_dir.mkdir(parents=True, exist_ok=True)
|
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
self.after(1000, lambda: messagebox.showwarning("Permission Notice",
|
|
f"Could not write to application folder.\nData is being saved to:\n{self.project_root}"))
|
|
|
|
# 3. Setup PATH for Backport.py and SRC folder
|
|
# When running as exe, these files are inside 'bundle_path' (sys._MEIPASS)
|
|
# We add them to sys.path so 'import Backport' works and src is accessible.
|
|
|
|
backport_file = bundle_path / "Backport.py"
|
|
src_folder = bundle_path / "src"
|
|
|
|
# Add paths to sys.path for imports
|
|
if str(bundle_path) not in sys.path:
|
|
sys.path.insert(0, str(bundle_path))
|
|
|
|
if src_folder.exists() and str(src_folder) not in sys.path:
|
|
sys.path.insert(0, str(src_folder))
|
|
|
|
# Store src path reference if needed for direct file access
|
|
self.src_path = src_folder if src_folder.exists() else (self.project_root / "src")
|
|
|
|
# =====================================================================
|
|
# STANDARD INITIALIZATION
|
|
# =====================================================================
|
|
|
|
# Changed: Now stores composite keys (source_path||internal_path) for deduplication
|
|
self.seen_game_keys = set()
|
|
self.seen_game_keys_lock = threading.Lock()
|
|
self.game_widgets = {}
|
|
self.c_7z_binary = self._find_7z_binary()
|
|
|
|
self.app_settings = self._load_app_settings()
|
|
self.games_cache = self._load_games_cache()
|
|
self._update_backport_dir()
|
|
|
|
ctk.set_appearance_mode(self.app_settings.get("appearance_mode", "dark"))
|
|
ctk.set_default_color_theme(self.app_settings.get("color_theme", "blue"))
|
|
|
|
self.enable_7z_var = tk.BooleanVar(value=self.app_settings.get("enable_7z", False))
|
|
self.appearance_mode_var = tk.StringVar(value=self.app_settings.get("appearance_mode", "dark"))
|
|
self.color_theme_var = tk.StringVar(value=self.app_settings.get("color_theme", "blue"))
|
|
self.max_scan_threads_var = tk.StringVar(value=str(self.app_settings.get("max_scan_threads", 4)))
|
|
self.max_backport_threads_var = tk.StringVar(value=str(self.app_settings.get("max_backport_threads", 4)))
|
|
self.view_mode_var = tk.StringVar(value=self.app_settings.get("view_mode", "List"))
|
|
self.backport_semaphore = threading.Semaphore(int(self.max_backport_threads_var.get()))
|
|
|
|
# Thread safety flag
|
|
self.is_scanning = False
|
|
|
|
self.tabview = ctk.CTkTabview(self)
|
|
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
|
|
|
|
self.main_tab = self.tabview.add("Main Page")
|
|
self.settings_tab = self.tabview.add("Settings")
|
|
|
|
self._setup_main_tab()
|
|
self._setup_settings_tab()
|
|
|
|
threading.Thread(target=self._periodic_sync_loop, daemon=True).start()
|
|
self.after(100, self.load_from_cache_on_startup)
|
|
|
|
def _sanitize_filename(self, name: str) -> str:
|
|
"""Remove invalid characters for Windows/Mac/Linux file systems."""
|
|
if not name: return name
|
|
invalid_chars = '<>:"/\\|?*\0'
|
|
return "".join(c if c not in invalid_chars else '_' for c in name)
|
|
|
|
def _update_backport_dir(self):
|
|
custom_dir = self.app_settings.get("custom_output_dir", "")
|
|
if custom_dir and os.path.isdir(custom_dir):
|
|
self.backport_dir = Path(custom_dir)
|
|
else:
|
|
self.backport_dir = self.project_root / "Backport"
|
|
self.backport_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _setup_main_tab(self):
|
|
self.instr_label = ctk.CTkLabel(self.main_tab, text="Select a folder to scan for games:", font=("Arial", 14))
|
|
self.instr_label.pack(pady=(10, 5))
|
|
|
|
self.scan_progress_frame = ctk.CTkFrame(self.main_tab, fg_color="transparent", height=20)
|
|
self.scan_progress_bar = ctk.CTkProgressBar(self.scan_progress_frame, height=8, corner_radius=4)
|
|
self.scan_progress_bar.pack(fill="x", padx=20, pady=(0, 5))
|
|
self.scan_progress_bar.set(0)
|
|
self.scan_progress_frame.pack_forget()
|
|
|
|
btn_frame = ctk.CTkFrame(self.main_tab, fg_color="transparent")
|
|
btn_frame.pack(pady=10)
|
|
self.browse_btn = ctk.CTkButton(btn_frame, text="Choose a folder", command=self.browse_folder)
|
|
self.browse_btn.pack(side="left", padx=5)
|
|
self.refresh_btn = ctk.CTkButton(btn_frame, text="⟳", width=35, height=28, command=self.force_refresh_cache, fg_color="transparent", border_width=1, text_color="gray")
|
|
self.refresh_btn.pack(side="left", padx=5)
|
|
|
|
self.status_label = ctk.CTkLabel(self.main_tab, text="Ready.", text_color="gray")
|
|
self.status_label.pack()
|
|
|
|
bottom_frame = ctk.CTkFrame(self.main_tab, fg_color="transparent")
|
|
bottom_frame.pack(side="bottom", pady=10)
|
|
self.backport_btn = ctk.CTkButton(bottom_frame, text="Start Backport All", fg_color="#8B5CF6", hover_color="#7C3AED", width=200, command=self.start_batch_backport)
|
|
self.backport_btn.pack()
|
|
|
|
self.scrollable_frame = ctk.CTkScrollableFrame(self.main_tab)
|
|
self.scrollable_frame.pack(fill="both", expand=True, padx=10, pady=10)
|
|
self._setup_global_scroll()
|
|
|
|
def _setup_settings_tab(self):
|
|
settings_container = ctk.CTkFrame(self.settings_tab, fg_color="transparent")
|
|
settings_container.pack(fill="both", expand=True)
|
|
|
|
sidebar = ctk.CTkFrame(settings_container, width=150)
|
|
sidebar.pack(side="left", fill="y", padx=(10, 5), pady=10)
|
|
sidebar.pack_propagate(False)
|
|
|
|
self.settings_content = ctk.CTkFrame(settings_container, fg_color="transparent")
|
|
self.settings_content.pack(side="left", fill="both", expand=True, padx=(5, 10), pady=10)
|
|
|
|
self.btn_general = ctk.CTkButton(sidebar, text="General", fg_color="transparent", text_color="gray", anchor="w", command=lambda: self.show_settings_page("general"))
|
|
self.btn_backport = ctk.CTkButton(sidebar, text="Backport", fg_color="transparent", text_color="gray", anchor="w", command=lambda: self.show_settings_page("backport"))
|
|
self.btn_credit = ctk.CTkButton(sidebar, text="Credit", fg_color="transparent", text_color="gray", anchor="w", command=lambda: self.show_settings_page("credit"))
|
|
|
|
self.btn_general.pack(pady=10, padx=10, fill="x")
|
|
self.btn_backport.pack(pady=10, padx=10, fill="x")
|
|
self.btn_credit.pack(pady=10, padx=10, fill="x")
|
|
|
|
# --- GENERAL PAGE ---
|
|
self.page_general = ctk.CTkFrame(self.settings_content)
|
|
ctk.CTkLabel(self.page_general, text="General Settings", font=("Arial", 20, "bold")).pack(pady=20)
|
|
|
|
path_frame = ctk.CTkFrame(self.page_general, fg_color="transparent")
|
|
path_frame.pack(fill="x", padx=20, pady=10)
|
|
ctk.CTkLabel(path_frame, text="Current Games Folder:", font=("Arial", 14, "bold")).grid(row=0, column=0, sticky="w", pady=5)
|
|
default_path = str(Path.home()) if self.platform != "windows" else "C:\\"
|
|
self.path_var = tk.StringVar(value=self.app_settings.get("latest_folder", default_path))
|
|
self.path_entry = ctk.CTkEntry(path_frame, textvariable=self.path_var, state="readonly")
|
|
self.path_entry.grid(row=0, column=1, sticky="ew", padx=10, pady=5)
|
|
path_frame.grid_columnconfigure(1, weight=1)
|
|
ctk.CTkButton(path_frame, text="Change Folder", width=120, command=self.browse_folder).grid(row=0, column=2, padx=(10, 0))
|
|
|
|
appearance_frame = ctk.CTkFrame(self.page_general, fg_color="transparent")
|
|
appearance_frame.pack(fill="x", padx=20, pady=5)
|
|
ctk.CTkLabel(appearance_frame, text="Appearance Mode:", font=("Arial", 14, "bold")).grid(row=0, column=0, sticky="w", pady=5)
|
|
ctk.CTkOptionMenu(appearance_frame, variable=self.appearance_mode_var, values=["dark", "light", "system"], command=self._apply_appearance, width=150).grid(row=0, column=1, padx=10, pady=5)
|
|
ctk.CTkLabel(appearance_frame, text="Color Theme:", font=("Arial", 14, "bold")).grid(row=1, column=0, sticky="w", pady=5)
|
|
ctk.CTkOptionMenu(appearance_frame, variable=self.color_theme_var, values=["blue", "green", "dark-blue"], command=self._apply_color_theme, width=150).grid(row=1, column=1, padx=10, pady=5)
|
|
|
|
view_frame = ctk.CTkFrame(self.page_general, fg_color="transparent")
|
|
view_frame.pack(fill="x", padx=20, pady=5)
|
|
ctk.CTkLabel(view_frame, text="Default View Mode:", font=("Arial", 14, "bold")).grid(row=0, column=0, sticky="w", pady=5)
|
|
ctk.CTkSegmentedButton(view_frame, variable=self.view_mode_var, values=["List", "Grid"], command=self._rebuild_view, width=150).grid(row=0, column=1, padx=10, pady=5)
|
|
|
|
ctk.CTkLabel(self.page_general, text="Performance & Multithreading", font=("Arial", 14, "bold")).pack(anchor="w", padx=20, pady=(20, 5))
|
|
perf_frame = ctk.CTkFrame(self.page_general, fg_color="transparent")
|
|
perf_frame.pack(fill="x", padx=20, pady=5)
|
|
ctk.CTkLabel(perf_frame, text="Max Scan Threads:").grid(row=0, column=0, sticky="w", pady=5)
|
|
ctk.CTkOptionMenu(perf_frame, variable=self.max_scan_threads_var, values=["1", "2", "4", "8"], width=150, command=lambda _: self._on_settings_changed()).grid(row=0, column=1, padx=10, pady=5)
|
|
ctk.CTkLabel(perf_frame, text="Max Backport Threads:").grid(row=1, column=0, sticky="w", pady=5)
|
|
ctk.CTkOptionMenu(perf_frame, variable=self.max_backport_threads_var, values=["1", "2", "4", "8"], width=150, command=self._apply_backport_threads).grid(row=1, column=1, padx=10, pady=5)
|
|
|
|
ctk.CTkLabel(self.page_general, text=f"Platform: {self.platform.capitalize()} | Config: {self.config_base}", text_color="gray", font=("Arial", 10)).pack(anchor="w", padx=20, pady=(10, 0))
|
|
|
|
# --- BACKPORT PAGE ---
|
|
self.page_backport = ctk.CTkFrame(self.settings_content)
|
|
ctk.CTkLabel(self.page_backport, text="Backport Settings", font=("Arial", 20, "bold")).pack(pady=(20, 10))
|
|
|
|
output_frame = ctk.CTkFrame(self.page_backport, fg_color=("gray85", "gray20"), corner_radius=8)
|
|
output_frame.pack(fill="x", padx=20, pady=(10, 15))
|
|
ctk.CTkLabel(output_frame, text="Output Folder", font=("Arial", 14, "bold")).grid(row=0, column=0, sticky="w", padx=15, pady=(10, 5))
|
|
self.output_path_var = tk.StringVar(value=str(self.backport_dir))
|
|
self.output_path_entry = ctk.CTkEntry(output_frame, textvariable=self.output_path_var, state="readonly", width=350)
|
|
self.output_path_entry.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 5))
|
|
output_btn_frame = ctk.CTkFrame(output_frame, fg_color="transparent")
|
|
output_btn_frame.grid(row=2, column=0, sticky="w", padx=15, pady=(0, 10))
|
|
ctk.CTkButton(output_btn_frame, text="Choose Folder", width=120, command=self.browse_output_folder).pack(side="left", padx=(0, 5))
|
|
ctk.CTkButton(output_btn_frame, text="Reset to Default", width=120, fg_color="transparent", border_width=1, text_color="gray", command=self.reset_output_folder).pack(side="left", padx=(0, 5))
|
|
ctk.CTkButton(output_btn_frame, text="Open Folder", width=100, fg_color="transparent", border_width=1, text_color="gray", command=self.open_output_folder).pack(side="left")
|
|
output_frame.grid_columnconfigure(0, weight=1)
|
|
ctk.CTkLabel(self.page_backport, text="If not set, defaults to the project's Backport folder.", text_color="gray", font=("Arial", 10)).pack(anchor="w", padx=20, pady=(0, 10))
|
|
|
|
seven_z_status = f"7z binary: {'Found (' + self.c_7z_binary + ')' if self.c_7z_binary else 'Not found'}"
|
|
ctk.CTkLabel(self.page_backport, text=seven_z_status, text_color="green" if self.c_7z_binary else "red", font=("Arial", 11)).pack(pady=(0, 10), padx=20, anchor="w")
|
|
self.checkbox_7z = ctk.CTkCheckBox(self.page_backport, text="Enable 7z support (very slow on solid archives)", variable=self.enable_7z_var, command=self._on_7z_setting_changed)
|
|
self.checkbox_7z.pack(pady=10, padx=20, anchor="w")
|
|
|
|
ctk.CTkLabel(self.page_backport, text="Fakelib Scanner (Auto-sort by name):", font=("Arial", 14, "bold")).pack(pady=(20, 5), anchor="w", padx=20)
|
|
fakelib_scan_frame = ctk.CTkFrame(self.page_backport, fg_color="transparent")
|
|
fakelib_scan_frame.pack(fill="x", padx=20, pady=5)
|
|
self.fakelib_scan_dir = tk.StringVar()
|
|
self.fakelib_path_label = ctk.CTkLabel(fakelib_scan_frame, text="No file/folder selected", anchor="w", text_color="gray")
|
|
self.fakelib_path_label.grid(row=0, column=0, sticky="ew", padx=(0, 10))
|
|
fakelib_scan_frame.grid_columnconfigure(0, weight=1)
|
|
ctk.CTkButton(fakelib_scan_frame, text="From Folder", width=90, command=self.browse_fakelib_folder).grid(row=0, column=1, padx=(0, 5))
|
|
ctk.CTkButton(fakelib_scan_frame, text="From Archive", width=90, fg_color="#1F6AA5", hover_color="#144870", command=self.browse_fakelib_archive).grid(row=0, column=2)
|
|
fakelib_action_frame = ctk.CTkFrame(self.page_backport, fg_color="transparent")
|
|
fakelib_action_frame.pack(fill="x", padx=20, pady=5)
|
|
ctk.CTkButton(fakelib_action_frame, text="Scan & Sort Selected", width=200, fg_color="#8B5CF6", hover_color="#7C3AED", command=self.scan_and_organize_fakelib).pack(anchor="w")
|
|
|
|
# --- CREDITS PAGE ---
|
|
self.page_credit = ctk.CTkFrame(self.settings_content)
|
|
ctk.CTkLabel(self.page_credit, text="Credits & Contributors", font=("Arial", 20, "bold")).pack(pady=20)
|
|
credits_data = [
|
|
("Nazky (Creator)", [("Twitter", "https://x.com/NazkyYT"), ("Ko-fi", "https://ko-fi.com/nazkyyt"), ("Github", "https://github.com/Nazky")]),
|
|
("BestPig", [("Twitter", "https://x.com/bestpig"), ("Ko-fi", "https://ko-fi.com/bestpig"), ("Github", "https://github.com/BestPig")]),
|
|
("IdleSauce", [("Github", "https://github.com/idlesauce")]),
|
|
("john-tornblom", [("Github", "https://github.com/john-tornblom")]),
|
|
("EchoStretch", [("Github", "https://github.com/EchoStretch"), ("Twitter", "https://x.com/StretchEcho"), ("Ko-fi", "https://ko-fi.com/echostretch")])
|
|
]
|
|
for name, links in credits_data:
|
|
card_frame = ctk.CTkFrame(self.page_credit, fg_color=("gray85", "gray20"), corner_radius=8)
|
|
card_frame.pack(fill="x", padx=20, pady=5)
|
|
ctk.CTkLabel(card_frame, text=name, font=("Arial", 16, "bold")).pack(anchor="w", padx=15, pady=(10, 5))
|
|
for platform_name, url in links:
|
|
lbl = ctk.CTkLabel(card_frame, text=f"{platform_name}: {url}", font=("Arial", 12), text_color="#1E90FF", anchor="w", cursor="hand2")
|
|
lbl.pack(anchor="w", padx=25, pady=1)
|
|
lbl.bind("<Button-1>", lambda e, u=url: webbrowser.open(u))
|
|
|
|
self.show_settings_page("general")
|
|
|
|
# --- UI Helpers ---
|
|
def show_settings_page(self, page_name):
|
|
for page in [self.page_general, self.page_backport, self.page_credit]: page.pack_forget()
|
|
for btn in [self.btn_general, self.btn_backport, self.btn_credit]: btn.configure(fg_color="transparent", text_color="gray")
|
|
pages = {"general": (self.page_general, self.btn_general), "backport": (self.page_backport, self.btn_backport), "credit": (self.page_credit, self.btn_credit)}
|
|
if page_name in pages:
|
|
pages[page_name][0].pack(fill="both", expand=True)
|
|
pages[page_name][1].configure(fg_color=("gray75", "gray30"), text_color="white")
|
|
if page_name == "general":
|
|
self.path_var.set(self.app_settings.get("latest_folder", str(Path.home())))
|
|
elif page_name == "backport":
|
|
self.output_path_var.set(str(self.backport_dir))
|
|
|
|
|
|
def _setup_global_scroll(self):
|
|
"""Bind mousewheel globally so it works over any child widget."""
|
|
if self.platform == "linux":
|
|
self.bind_all("<Button-4>", self._on_mousewheel)
|
|
self.bind_all("<Button-5>", self._on_mousewheel)
|
|
else:
|
|
self.bind_all("<MouseWheel>", self._on_mousewheel)
|
|
|
|
def _on_mousewheel(self, event):
|
|
"""Handle mousewheel scroll only if the mouse is over the game list."""
|
|
try:
|
|
# Find the exact widget under the mouse cursor
|
|
widget_under_mouse = self.winfo_containing(event.x_root, event.y_root)
|
|
|
|
if widget_under_mouse is None:
|
|
return
|
|
|
|
# Check if the widget is inside our scrollable frame
|
|
if not self._is_descendant_of(widget_under_mouse, self.scrollable_frame):
|
|
return
|
|
|
|
# Check if the widget is an interactive element (buttons, dropdowns, etc.)
|
|
if self._is_interactive_widget(widget_under_mouse):
|
|
return
|
|
|
|
# Perform the scroll
|
|
if self.platform == "linux":
|
|
if event.num == 4:
|
|
self.scrollable_frame._parent_canvas.yview_scroll(-1, "units")
|
|
elif event.num == 5:
|
|
self.scrollable_frame._parent_canvas.yview_scroll(1, "units")
|
|
else:
|
|
if self.platform == "windows":
|
|
units = -1 if event.delta > 0 else 1
|
|
else: # macOS
|
|
units = -event.delta
|
|
self.scrollable_frame._parent_canvas.yview_scroll(units, "units")
|
|
|
|
except Exception:
|
|
# Prevent crashes if a widget is destroyed while scrolling
|
|
pass
|
|
|
|
def _is_descendant_of(self, widget, parent):
|
|
"""Check if a widget is a descendant of a specific parent widget."""
|
|
current = widget
|
|
while current is not None:
|
|
if current == parent:
|
|
return True
|
|
current = current.master
|
|
return False
|
|
|
|
def _is_interactive_widget(self, widget):
|
|
"""Check if widget or any of its parents is an interactive CTk element."""
|
|
interactive_types = {
|
|
'CTkButton', 'CTkCheckBox', 'CTkOptionMenu',
|
|
'CTkSegmentedButton', 'CTkEntry', 'CTkSlider',
|
|
'CTkSwitch', 'CTkRadioButton', 'CTkComboBox', 'CTkTextbox'
|
|
}
|
|
|
|
current = widget
|
|
max_depth = 15 # Prevent infinite loops
|
|
while current is not None and max_depth > 0:
|
|
widget_type = type(current).__name__
|
|
if widget_type in interactive_types:
|
|
return True
|
|
current = current.master
|
|
max_depth -= 1
|
|
|
|
return False
|
|
|
|
def _find_7z_binary(self):
|
|
# Thanks to the environment fix, shutil.which works reliably on macOS now
|
|
binary = shutil.which('7z')
|
|
if binary: return binary
|
|
|
|
paths = { "windows": [r"C:\Program Files\7-Zip\7z.exe", r"C:\Program Files (x86)\7-Zip\7z.exe"], "macos": ["/opt/homebrew/bin/7z", "/usr/local/bin/7z"], "linux": ["/usr/bin/7z", "/usr/local/bin/7z", "/snap/bin/7z"] }.get(self.platform, [])
|
|
for path in paths:
|
|
if os.path.exists(path): return path
|
|
return None
|
|
|
|
def browse_output_folder(self):
|
|
selected = filedialog.askdirectory(title="Select Output Folder", initialdir=str(self.backport_dir))
|
|
if selected:
|
|
self.app_settings["custom_output_dir"] = selected
|
|
self._update_backport_dir()
|
|
self.output_path_var.set(str(self.backport_dir))
|
|
self._on_settings_changed()
|
|
|
|
def reset_output_folder(self):
|
|
self.app_settings["custom_output_dir"] = ""
|
|
self._update_backport_dir()
|
|
self.output_path_var.set(str(self.backport_dir))
|
|
self._on_settings_changed()
|
|
|
|
def open_output_folder(self):
|
|
if not self.backport_dir.exists(): self.backport_dir.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
if self.platform == "windows": os.startfile(str(self.backport_dir))
|
|
elif self.platform == "macos": subprocess.Popen(['open', str(self.backport_dir)])
|
|
else: subprocess.Popen(['xdg-open', str(self.backport_dir)])
|
|
except Exception as e: messagebox.showerror("Error", str(e))
|
|
|
|
# --- Settings Management ---
|
|
def _on_settings_changed(self):
|
|
self.app_settings["enable_7z"] = self.enable_7z_var.get()
|
|
self.app_settings["appearance_mode"] = self.appearance_mode_var.get()
|
|
self.app_settings["color_theme"] = self.color_theme_var.get()
|
|
try: self.app_settings["max_scan_threads"] = int(self.max_scan_threads_var.get()); self.app_settings["max_backport_threads"] = int(self.max_backport_threads_var.get())
|
|
except: pass
|
|
self.app_settings["view_mode"] = self.view_mode_var.get()
|
|
try:
|
|
with open(self.settings_file, 'w', encoding='utf-8') as f: json.dump(self.app_settings, f, indent=4)
|
|
except: pass
|
|
|
|
def _on_7z_setting_changed(self):
|
|
self._on_settings_changed()
|
|
|
|
def _apply_appearance(self, mode): ctk.set_appearance_mode(mode); self._on_settings_changed()
|
|
def _apply_color_theme(self, theme): ctk.set_default_color_theme(theme); self._on_settings_changed()
|
|
def _apply_backport_threads(self, val):
|
|
try: self.backport_semaphore = threading.Semaphore(int(val))
|
|
except: pass
|
|
self._on_settings_changed()
|
|
|
|
# --- Thread Safe UI Calls ---
|
|
def _thread_safe_status(self, text, color): self.after(0, self.status_label.configure, {"text": text, "text_color": color})
|
|
def _thread_safe_add(self, game_data, pil_image, source_type, source_path, internal_path=""): self.after(0, self.add_to_gui, game_data, pil_image, source_type, source_path, internal_path)
|
|
def _thread_safe_btn_text(self, widget_key, text):
|
|
info = self.game_widgets.get(widget_key)
|
|
if info and info.get("btn_start"): self.after(0, info["btn_start"].configure, {"text": text})
|
|
def _thread_safe_progress(self, widget_key, value):
|
|
info = self.game_widgets.get(widget_key)
|
|
if info and info.get("progress"): self.after(0, info["progress"].set, value)
|
|
def _thread_safe_time(self, widget_key, text):
|
|
info = self.game_widgets.get(widget_key)
|
|
if info and info.get("time_label"): self.after(0, info["time_label"].configure, {"text": text})
|
|
def _show_scan_progress(self): self.scan_progress_bar.set(0); self.after(0, lambda: self.scan_progress_frame.pack(before=self.instr_label, fill="x", pady=(0, 0)))
|
|
def _hide_scan_progress(self): self.after(0, self.scan_progress_frame.pack_forget)
|
|
def _update_scan_progress(self, value): self.after(0, self.scan_progress_bar.set, value)
|
|
|
|
# --- View & Cache Management ---
|
|
def _rebuild_view(self, value=None):
|
|
self._on_settings_changed()
|
|
mode = "grid" if self.view_mode_var.get() == "Grid" else "list"
|
|
cols = 3 if mode == "grid" else 1
|
|
for widget in self.scrollable_frame.winfo_children(): widget.destroy()
|
|
self.scrollable_frame.grid_columnconfigure((0, 1, 2), weight=1) if mode == "grid" else [self.scrollable_frame.grid_columnconfigure(i, weight=0) for i in range(3)]
|
|
for i, (widget_key, info) in enumerate(self.game_widgets.items()):
|
|
row, col = i // cols, i % cols
|
|
internal_path = info.get('internal_path', '')
|
|
p, s, b, fv, fav, fm, t, pf = self.create_list_item(
|
|
info["data"], info.get("pil_image"), info["source_type"], widget_key,
|
|
mode=mode, row=row, col=col, internal_path=internal_path
|
|
)
|
|
info.update({"progress": p, "status_label": s, "btn_start": b, "fakelib_var": fv, "fakelib_all_var": fav, "fakelib_menu": fm, "time_label": t, "progress_frame": pf})
|
|
if info["is_processing"]:
|
|
if mode == "list": info['progress_frame'].pack(side="bottom", fill="x", padx=10, pady=(0, 5))
|
|
info['btn_start'].configure(text="Working...", state="disabled"); info['progress'].set(0.5)
|
|
|
|
def get_available_fakelib_versions(self):
|
|
versions = ["None"]
|
|
if self.backport_dir.exists():
|
|
found = [int(f.name.replace("fakelib-", "")) for f in self.backport_dir.iterdir() if f.is_dir() and f.name.startswith("fakelib-") and f.name.replace("fakelib-", "").isdigit()]
|
|
versions.extend(str(v) for v in sorted(found))
|
|
return versions
|
|
|
|
def browse_fakelib_folder(self):
|
|
p = filedialog.askdirectory(title="Select Fakelib Root Folder")
|
|
if p: self.fakelib_path_label.configure(text=f"[Folder] {'...' + p[-42:] if len(p) > 45 else p}", text_color="white"); self.fakelib_scan_dir.set(p)
|
|
|
|
def browse_fakelib_archive(self):
|
|
p = filedialog.askopenfilename(title="Select Fakelib Archive", filetypes=[("Archives", "*.zip *.rar *.7z")])
|
|
if p: self.fakelib_path_label.configure(text=f"[Archive] {Path(p).name}", text_color="white"); self.fakelib_scan_dir.set(p)
|
|
|
|
def scan_and_organize_fakelib(self):
|
|
import shutil
|
|
scan_path = self.fakelib_scan_dir.get()
|
|
if not scan_path or not os.path.exists(scan_path):
|
|
messagebox.showerror("Error", "Select a valid file/folder first.");
|
|
return
|
|
|
|
valid_exts = ('.prx', '.sprx', '.self', '.bin', '.elf')
|
|
is_archive = os.path.isfile(scan_path) and Path(scan_path).suffix.lower().lstrip('.') in ['zip', 'rar', '7z']
|
|
copied = 0
|
|
sdks = set()
|
|
|
|
try:
|
|
if is_archive:
|
|
real_type = self.detect_real_archive_type(scan_path)
|
|
if not real_type:
|
|
messagebox.showerror("Error", "Unsupported archive type or corrupt file.")
|
|
return
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
try:
|
|
self._extract_all_archive_files(scan_path, real_type, tmp)
|
|
except Exception as e:
|
|
messagebox.showerror("Extraction Error", str(e))
|
|
return
|
|
|
|
archive_root = tmp
|
|
tmp_path = Path(tmp)
|
|
top_level_items = list(tmp_path.iterdir())
|
|
if len(top_level_items) == 1 and top_level_items[0].is_dir():
|
|
archive_root = str(top_level_items[0])
|
|
|
|
for root, dirs, files in os.walk(tmp):
|
|
sdk_det = self._get_sdk_from_path(root, archive_root)
|
|
|
|
if sdk_det:
|
|
dest = self.backport_dir / f"fakelib-{sdk_det}"
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
sdks.add(sdk_det)
|
|
|
|
for fn in files:
|
|
if fn.lower().endswith(valid_exts):
|
|
src = os.path.join(root, fn)
|
|
dst = dest / fn
|
|
if not dst.exists():
|
|
shutil.copy2(src, dst)
|
|
copied += 1
|
|
|
|
if not sdks:
|
|
has_files = any(
|
|
fn.lower().endswith(valid_exts)
|
|
for r, _, files in os.walk(tmp)
|
|
for fn in files
|
|
)
|
|
|
|
if has_files:
|
|
det = self._extract_sdk_from_name(Path(scan_path).name)
|
|
if not det:
|
|
det = simpledialog.askstring("Fakelib SDK", "Found files, but no SDK folders.\nWhat FW/SDK should these go into? (e.g., 4, 7):", parent=self)
|
|
|
|
if det:
|
|
dest = self.backport_dir / f"fakelib-{det}"
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
sdks.add(det)
|
|
|
|
for r, _, f in os.walk(tmp):
|
|
for fn in f:
|
|
if fn.lower().endswith(valid_exts):
|
|
src = os.path.join(r, fn)
|
|
dst = dest / fn
|
|
if not dst.exists():
|
|
shutil.copy2(src, dst)
|
|
copied += 1
|
|
else:
|
|
messagebox.showwarning("Wrong Files", f"Extracted files, but NONE of them end in {valid_exts}.")
|
|
|
|
else:
|
|
for root, dirs, files in os.walk(scan_path):
|
|
sdk_det = self._get_sdk_from_path(root, scan_path)
|
|
|
|
if sdk_det:
|
|
dest = self.backport_dir / f"fakelib-{sdk_det}"
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
sdks.add(sdk_det)
|
|
|
|
for fn in files:
|
|
if fn.lower().endswith(valid_exts):
|
|
src = os.path.join(root, fn)
|
|
dst = dest / fn
|
|
if not dst.exists():
|
|
shutil.copy2(src, dst)
|
|
copied += 1
|
|
|
|
if not sdks:
|
|
has_files = any(
|
|
fn.lower().endswith(valid_exts)
|
|
for r, _, files in os.walk(scan_path)
|
|
for fn in files
|
|
)
|
|
if has_files:
|
|
det = simpledialog.askstring("SDK?", f"Found files, but no SDK folders. What FW/SDK for these files?")
|
|
if det:
|
|
dest = self.backport_dir / f"fakelib-{det}"
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
sdks.add(det)
|
|
for r, _, f in os.walk(scan_path):
|
|
for fn in f:
|
|
if fn.lower().endswith(valid_exts):
|
|
src = os.path.join(r, fn)
|
|
dst = dest / fn
|
|
if not dst.exists():
|
|
shutil.copy2(src, dst)
|
|
copied += 1
|
|
else:
|
|
messagebox.showwarning("Empty", "No valid fakelib files were found in the folder.")
|
|
|
|
self._update_fakelib_dropdowns()
|
|
|
|
if sdks:
|
|
status_msg = f"Processed SDKs: {', '.join(sorted(sdks))}"
|
|
if copied > 0:
|
|
status_msg += f"\nCopied {copied} new files."
|
|
else:
|
|
status_msg += "\nAll files already exist (0 new files copied)."
|
|
|
|
messagebox.showinfo("Done", status_msg)
|
|
self.fakelib_path_label.configure(text=f"Done! (SDKs: {', '.join(sorted(sdks))})", text_color="#00FF00")
|
|
else:
|
|
self.fakelib_path_label.configure(text="No valid files found", text_color="#FFA500")
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Error", str(e))
|
|
|
|
def _extract_sdk_from_name(self, name):
|
|
m = re.search(r'(?:fakelib|sdk)[\s\-_]?(\d+(?:\.\d+)?)', name, re.IGNORECASE)
|
|
return m.group(1).split('.')[0] if m else None
|
|
|
|
def _get_sdk_from_path(self, current_root, base_root):
|
|
try:
|
|
rel_path = os.path.relpath(current_root, base_root)
|
|
if rel_path == '.':
|
|
return None
|
|
parts = Path(rel_path).parts
|
|
for part in parts:
|
|
sdk = self._extract_sdk_from_name(part)
|
|
if sdk:
|
|
return sdk
|
|
except ValueError:
|
|
pass
|
|
return None
|
|
|
|
def _update_fakelib_dropdowns(self):
|
|
opts = self.get_available_fakelib_versions()
|
|
for info in self.game_widgets.values():
|
|
if info.get("fakelib_menu"):
|
|
info["fakelib_menu"].configure(values=opts)
|
|
if info["fakelib_var"].get() == "None" and len(opts) > 1: info["fakelib_var"].set(opts[1])
|
|
|
|
# --- Core Backport Logic ---
|
|
def start_single_backport(self, widget_key):
|
|
info = self.game_widgets.get(widget_key)
|
|
if not info or info.get('is_processing'):
|
|
return
|
|
|
|
if not self.enable_7z_var.get() and ".7Z" in info.get('source_type', ""):
|
|
info['status_label'].configure(text="Skipped (7z disabled)", text_color="#FFA500")
|
|
return
|
|
|
|
do_all_fakelib = info['fakelib_all_var'].get()
|
|
selected_fakelib = info['fakelib_var'].get()
|
|
|
|
if not do_all_fakelib and selected_fakelib == "None":
|
|
continue_without = messagebox.askyesno(
|
|
"No Fakelib Selected",
|
|
"Warning: You haven't selected a fakelib version.\nThe backport may not work correctly without one.\n\nDo you want to continue anyway?",
|
|
icon='warning'
|
|
)
|
|
|
|
if not continue_without:
|
|
return
|
|
|
|
manual_sdk = simpledialog.askstring(
|
|
"Input SDK Version",
|
|
"Since no fakelib is selected, please enter the target SDK version number:\n(e.g., 4, 7, 8)",
|
|
parent=self
|
|
)
|
|
|
|
if not manual_sdk:
|
|
return
|
|
|
|
try:
|
|
sdk_val = int(manual_sdk)
|
|
if sdk_val <= 0:
|
|
messagebox.showerror("Invalid Input", "SDK version must be a positive number.")
|
|
return
|
|
info['manual_sdk_override'] = sdk_val
|
|
except ValueError:
|
|
messagebox.showerror("Invalid Input", "Please enter a valid whole number for the SDK version.")
|
|
return
|
|
else:
|
|
info['manual_sdk_override'] = None
|
|
|
|
info['btn_start'].configure(text="Starting...", state="disabled")
|
|
mode = "grid" if self.view_mode_var.get() == "Grid" else "list"
|
|
if mode == "list":
|
|
info['progress_frame'].pack(side="bottom", fill="x", padx=10, pady=(0, 5))
|
|
info['progress'].set(0.1)
|
|
info['is_processing'] = True
|
|
threading.Thread(target=self._backport_worker, args=(widget_key,), daemon=True).start()
|
|
|
|
def start_batch_backport(self):
|
|
for widget_key, info in self.game_widgets.items():
|
|
if not info.get('is_processing') and not info["is_backported"]:
|
|
info['fakelib_all_var'].set(True)
|
|
self.start_single_backport(widget_key)
|
|
|
|
def open_backport_folder(self, widget_key):
|
|
info = self.game_widgets.get(widget_key)
|
|
if not info: return
|
|
data = info["data"]
|
|
safe_title = self._sanitize_filename(data["titleName"])
|
|
out_name = f"{safe_title} - {data['titleId']} - {data['contentVersion']}"
|
|
fp = self.backport_dir / out_name
|
|
if not fp.exists(): messagebox.showinfo("Not Found", f"No backport folder yet for:\n{data['titleName']}"); return
|
|
try:
|
|
if self.platform == "windows": os.startfile(str(fp))
|
|
elif self.platform == "macos": subprocess.Popen(['open', str(fp)])
|
|
else: subprocess.Popen(['xdg-open', str(fp)])
|
|
except Exception as e: messagebox.showerror("Error", str(e))
|
|
|
|
def _progress_animation_worker(self, widget_key, stop_event):
|
|
val = 0.1
|
|
while not stop_event.is_set():
|
|
val = min(val + 0.005, 0.9)
|
|
self._thread_safe_progress(widget_key, val); time.sleep(0.1)
|
|
|
|
def _backport_worker(self, widget_key):
|
|
with self.backport_semaphore:
|
|
info = self.game_widgets[widget_key]
|
|
source_path = info['source_path']; internal_path = info.get('internal_path', "")
|
|
data = info['data']
|
|
|
|
print(f"\n[BACKPORT] ========================================", flush=True)
|
|
print(f"[BACKPORT] Starting: {data['titleName']} ({data['titleId']})", flush=True)
|
|
|
|
safe_title = self._sanitize_filename(data['titleName'])
|
|
out_name = f"{safe_title} - {data['titleId']} - {data['contentVersion']}"
|
|
|
|
base_output_dir = self.backport_dir / out_name
|
|
temp_extract_dir = None; overall_success = True; folders_processed = []
|
|
is_archive = Path(source_path).is_file() and Path(source_path).suffix.lower().lstrip('.') in ['zip', 'rar', '7z']
|
|
|
|
if is_archive:
|
|
real_type = self.detect_real_archive_type(source_path)
|
|
print(f"[BACKPORT] Source: {real_type.upper()} Archive -> {Path(source_path).name}", flush=True)
|
|
if real_type == '7z' and not self.enable_7z_var.get():
|
|
print("[BACKPORT] ABORTED: 7z support is disabled in settings.", flush=True)
|
|
self.after(0, self._finish_backport, widget_key, False, [], "Skipped (7z disabled)", 0.0)
|
|
return
|
|
else:
|
|
print(f"[BACKPORT] Source: Normal Folder -> {source_path}", flush=True)
|
|
|
|
start_time = time.time(); progress_stop_event = threading.Event()
|
|
if not is_archive:
|
|
self._thread_safe_btn_text(widget_key, "Backporting...")
|
|
threading.Thread(target=self._progress_animation_worker, args=(widget_key, progress_stop_event), daemon=True).start()
|
|
|
|
try:
|
|
from Backport import PS5ELFProcessor
|
|
|
|
processor = PS5ELFProcessor(use_colors=True)
|
|
paid = 0x3100000000000002
|
|
ptype = 1
|
|
|
|
input_dir_to_process = source_path
|
|
|
|
if is_archive:
|
|
self._thread_safe_btn_text(widget_key, "Extracting..."); self._thread_safe_progress(widget_key, 0.2)
|
|
real_type = self.detect_real_archive_type(source_path)
|
|
|
|
print(f"[BACKPORT] Extracting entire archive to temp directory...", flush=True)
|
|
temp_extract_dir = tempfile.mkdtemp(prefix='ps5_backport_')
|
|
|
|
try:
|
|
self._extract_required_files_only(source_path, real_type, temp_extract_dir, internal_path)
|
|
print(f"[BACKPORT] Extraction successful!", flush=True)
|
|
except Exception as e:
|
|
err_msg = str(e)
|
|
print(f"[BACKPORT] EXTRACTION FAILED: {err_msg}", flush=True)
|
|
shutil.rmtree(temp_extract_dir, ignore_errors=True)
|
|
temp_extract_dir = None
|
|
raise Exception(f"Failed to extract: {err_msg}")
|
|
|
|
if internal_path:
|
|
input_dir_to_process = str(Path(temp_extract_dir) / internal_path)
|
|
if not os.path.exists(input_dir_to_process):
|
|
print(f"[BACKPORT] WARNING: Internal path '{internal_path}' not found, falling back to root.", flush=True)
|
|
input_dir_to_process = temp_extract_dir
|
|
else:
|
|
input_dir_to_process = temp_extract_dir
|
|
|
|
print(f"[BACKPORT] Input directory for pipeline: {input_dir_to_process}", flush=True)
|
|
self._thread_safe_progress(widget_key, 0.4)
|
|
|
|
selected_fakelib = info['fakelib_var'].get()
|
|
do_all_fakelib = info['fakelib_all_var'].get()
|
|
print(f"[BACKPORT] Fakelib: Selected='{selected_fakelib}', DoAll={do_all_fakelib}", flush=True)
|
|
|
|
if do_all_fakelib:
|
|
fakelib_folders = [f.name for f in self.backport_dir.iterdir() if f.is_dir() and f.name.startswith("fakelib-")]
|
|
total = len(fakelib_folders)
|
|
print(f"[BACKPORT] Batch backporting with {total} fakelib versions...", flush=True)
|
|
for index, folder in enumerate(fakelib_folders):
|
|
sdk_pair = int(folder.replace("fakelib-", ""))
|
|
self._thread_safe_btn_text(widget_key, f"BP {index+1}/{total} (SDK {sdk_pair})")
|
|
specific_out = base_output_dir / folder; folders_processed.append(folder)
|
|
if not self._execute_single_backport(processor, input_dir_to_process, str(specific_out), sdk_pair, paid, ptype, str(self.backport_dir / folder)): overall_success = False
|
|
prog = (0.4 + (0.5 * (index + 1) / total)) if is_archive else (0.2 + (0.7 * (index + 1) / total))
|
|
self._thread_safe_progress(widget_key, prog)
|
|
else:
|
|
sdk_pair = int(selected_fakelib) if selected_fakelib != "None" else 7
|
|
|
|
out_dir = str(base_output_dir)
|
|
fakelib_path = None
|
|
if selected_fakelib != "None":
|
|
sdk_pair = int(selected_fakelib)
|
|
fakelib_folder_name = f"fakelib-{selected_fakelib}"
|
|
fakelib_path = str(self.backport_dir / fakelib_folder_name)
|
|
out_dir = str(base_output_dir / fakelib_folder_name)
|
|
folders_processed.append(fakelib_folder_name)
|
|
else:
|
|
sdk_pair = info.get('manual_sdk_override', 7)
|
|
fakelib_path = None
|
|
out_dir = str(base_output_dir)
|
|
folders_processed.append("No Fakelib")
|
|
|
|
print(f"[BACKPORT] Executing single backport: SDK={sdk_pair}", flush=True)
|
|
self._thread_safe_btn_text(widget_key, f"Backporting (SDK {sdk_pair})")
|
|
self._thread_safe_progress(widget_key, 0.6)
|
|
|
|
if not self._execute_single_backport(processor, input_dir_to_process, out_dir, sdk_pair, paid, ptype, fakelib_path): overall_success = False
|
|
|
|
self._thread_safe_btn_text(widget_key, "Finishing..."); self._thread_safe_progress(widget_key, 0.95)
|
|
|
|
except Exception as e:
|
|
error_msg = str(e)
|
|
print(f"[BACKPORT ERROR] Pipeline failed: {error_msg}", flush=True)
|
|
import traceback
|
|
traceback.print_exc()
|
|
overall_success = False
|
|
finally:
|
|
if not is_archive: progress_stop_event.set()
|
|
if temp_extract_dir:
|
|
print(f"[BACKPORT] Cleaning up temp directory...", flush=True)
|
|
shutil.rmtree(temp_extract_dir, ignore_errors=True)
|
|
elapsed_total = time.time() - start_time
|
|
print(f"[BACKPORT] ========================================", flush=True)
|
|
print(f"[BACKPORT] Completed in {elapsed_total:.2f}s. Success={overall_success}\n", flush=True)
|
|
|
|
self.after(0, self._finish_backport, widget_key, overall_success, folders_processed, error_msg if not overall_success else "", elapsed_total)
|
|
|
|
def _finish_backport(self, widget_key, success, processed, msg, elapsed_time):
|
|
info = self.game_widgets.get(widget_key)
|
|
if not info: return
|
|
info['btn_start'].configure(text="Start", state="normal")
|
|
info['is_processing'] = False
|
|
time_str = f"{elapsed_time:.1f}s" if elapsed_time > 0 else ""
|
|
|
|
if success:
|
|
info['progress'].set(1.0)
|
|
|
|
is_backported, bp_info = self.check_backport_status(info["data"]["titleName"], info["data"]["titleId"], info["data"]["contentVersion"])
|
|
|
|
if is_backported:
|
|
status_text = f"Backported ({', '.join(bp_info)})" if bp_info else "Backported"
|
|
info['status_label'].configure(text=status_text, text_color="#00FF00")
|
|
else:
|
|
info['status_label'].configure(text="Backported", text_color="#00FF00")
|
|
|
|
info['is_backported'] = True
|
|
else:
|
|
info['progress'].set(0)
|
|
info['status_label'].configure(text=f"Failed: {msg}", text_color="#FF4444")
|
|
|
|
if time_str:
|
|
info['time_label'].configure(text=time_str, text_color="#00FF00" if success else "#FF4444")
|
|
|
|
def _execute_single_backport(self, processor, input_dir, output_dir, sdk_pair, paid, ptype, fakelib_source):
|
|
res = processor.decrypt_and_sign_pipeline(input_dir=input_dir, output_dir=output_dir, sdk_pair=sdk_pair, paid=paid, ptype=ptype, fakelib_source=fakelib_source, create_backup=True, overwrite=False, apply_libc_patch=True, auto_revert_for_high_sdk=True, verbose=True)
|
|
return not (res.get('failed', 0) > 0 or res.get('decrypt', {}).get('failed', 0) > 0)
|
|
|
|
def check_backport_status(self, title_name, title_id, game_version):
|
|
safe_title = self._sanitize_filename(title_name)
|
|
folder_path = self.backport_dir / f"{safe_title} - {title_id} - {game_version}"
|
|
if not folder_path.is_dir(): return False, []
|
|
fakelib_found = []
|
|
try:
|
|
for item in folder_path.iterdir():
|
|
if item.is_dir() and item.name.startswith("fakelib-") and any(item.iterdir()): fakelib_found.append(item.name)
|
|
if any(item.is_file() and not item.name.startswith(".") for item in folder_path.iterdir()): fakelib_found.insert(0, "direct")
|
|
except OSError: pass
|
|
return len(fakelib_found) > 0, fakelib_found
|
|
|
|
def detect_real_archive_type(self, filepath):
|
|
try:
|
|
with open(filepath, 'rb') as f: header = f.read(6)
|
|
if header[:2] == b'PK': return 'zip'
|
|
elif header[:4] == b'Rar!': return 'rar'
|
|
elif header[:6] == b'7z\xbc\xaf\x27\x1c': return '7z'
|
|
except: pass
|
|
return None
|
|
|
|
# --- Archive Helpers ---
|
|
def _extract_archive_files(self, archive_path, real_type, dest_dir, valid_exts):
|
|
try:
|
|
if real_type == 'zip':
|
|
with zipfile.ZipFile(archive_path, 'r') as z:
|
|
for f in z.namelist():
|
|
if Path(f).suffix.lower() in valid_exts: z.extract(f, dest_dir)
|
|
elif real_type == 'rar':
|
|
with rarfile.RarFile(archive_path, 'r') as z:
|
|
for f in z.namelist():
|
|
if Path(f).suffix.lower() in valid_exts: z.extract(f, dest_dir)
|
|
elif real_type == '7z':
|
|
with py7zr.SevenZipFile(archive_path, 'r') as z:
|
|
targets = [n for n in z.getnames() if Path(n).suffix.lower() in valid_exts]
|
|
if targets: z.extract(targets=targets, path=dest_dir)
|
|
except Exception as e: print(f"[EXTRACT ERROR] {e}")
|
|
|
|
def _extract_all_archive_files(self, archive_path, real_type, dest_dir):
|
|
"""Extracts ALL files from an archive to a destination directory."""
|
|
try:
|
|
if real_type == 'zip':
|
|
with zipfile.ZipFile(archive_path, 'r') as z:
|
|
z.extractall(dest_dir)
|
|
elif real_type == 'rar':
|
|
with rarfile.RarFile(archive_path, 'r') as z:
|
|
z.extractall(dest_dir)
|
|
elif real_type == '7z':
|
|
with py7zr.SevenZipFile(archive_path, 'r') as z:
|
|
z.extractall(path=dest_dir)
|
|
except Exception as e:
|
|
raise Exception(f"Failed to extract archive: {e}")
|
|
|
|
def _extract_required_files_only(self, archive_path, real_type, dest_dir, internal_path=""):
|
|
"""Extract ONLY the files required for backporting (param.json + executables)."""
|
|
try:
|
|
targets_to_extract = []
|
|
exec_exts = ['.self', '.prx', '.sprx', '.elf']
|
|
|
|
# 1. Figure out which files we need
|
|
if real_type == 'zip':
|
|
with zipfile.ZipFile(archive_path, 'r') as zf:
|
|
for name in zf.namelist():
|
|
norm = name.replace('\\', '/').lower()
|
|
if internal_path and not norm.startswith(internal_path.lower() + '/'):
|
|
continue
|
|
if norm.endswith('sce_sys/param.json') or norm.endswith('eboot.bin'):
|
|
targets_to_extract.append(name)
|
|
elif any(norm.endswith(ext) for ext in exec_exts):
|
|
targets_to_extract.append(name)
|
|
|
|
elif real_type == 'rar':
|
|
with rarfile.RarFile(archive_path, 'r') as rf:
|
|
for name in rf.namelist():
|
|
norm = name.replace('\\', '/').lower()
|
|
if internal_path and not norm.startswith(internal_path.lower() + '/'):
|
|
continue
|
|
if norm.endswith('sce_sys/param.json') or norm.endswith('eboot.bin'):
|
|
targets_to_extract.append(name)
|
|
elif any(norm.endswith(ext) for ext in exec_exts):
|
|
targets_to_extract.append(name)
|
|
|
|
elif real_type == '7z':
|
|
with py7zr.SevenZipFile(archive_path, 'r') as szf:
|
|
for name in szf.getnames():
|
|
norm = name.replace('\\', '/').lower()
|
|
if internal_path and not norm.startswith(internal_path.lower() + '/'):
|
|
continue
|
|
if norm.endswith('sce_sys/param.json') or norm.endswith('eboot.bin'):
|
|
targets_to_extract.append(name)
|
|
elif any(norm.endswith(ext) for ext in exec_exts):
|
|
targets_to_extract.append(name)
|
|
|
|
if not targets_to_extract:
|
|
raise Exception(f"No backportable executable files found inside archive.")
|
|
|
|
print(f"[BACKPORT] Smart extract: Found {len(targets_to_extract)} required file(s) to extract.", flush=True)
|
|
for t in targets_to_extract[:5]:
|
|
print(f"[BACKPORT] - {t}", flush=True)
|
|
if len(targets_to_extract) > 5:
|
|
print(f"[BACKPORT] - ... and {len(targets_to_extract) - 5} more", flush=True)
|
|
|
|
# 2. Extract ONLY those files
|
|
if real_type == 'zip':
|
|
with zipfile.ZipFile(archive_path, 'r') as zf:
|
|
for name in targets_to_extract:
|
|
zf.extract(name, dest_dir)
|
|
|
|
elif real_type == 'rar':
|
|
with rarfile.RarFile(archive_path, 'r') as rf:
|
|
rf.extractall(dest_dir, members=targets_to_extract)
|
|
|
|
elif real_type == '7z':
|
|
try:
|
|
# Try targeted extraction first (fast for non-solid archives)
|
|
with py7zr.SevenZipFile(archive_path, 'r') as szf:
|
|
szf.extract(targets=targets_to_extract, path=dest_dir)
|
|
except Exception as e:
|
|
print(f"[BACKPORT] 7z targeted extract failed ({e}).", flush=True)
|
|
print(f"[BACKPORT] Falling back to safe extraction...", flush=True)
|
|
|
|
# Fallback: Use py7zr to extract all, but we will delete unnecessary files after
|
|
with py7zr.SevenZipFile(archive_path, 'r') as szf:
|
|
szf.extractall(path=dest_dir)
|
|
|
|
# If it was a multi-game archive, delete the other games to save space
|
|
if internal_path:
|
|
temp_root = Path(dest_dir)
|
|
for item in temp_root.iterdir():
|
|
if item.name.lower() != internal_path.lower().split('/')[0]:
|
|
if item.is_dir():
|
|
shutil.rmtree(item, ignore_errors=True)
|
|
else:
|
|
item.unlink()
|
|
|
|
except Exception as e:
|
|
raise Exception(f"Smart extraction failed: {e}")
|
|
|
|
# =====================================================================
|
|
# PS5 GAME SCANNING IMPLEMENTATION
|
|
# =====================================================================
|
|
|
|
def _find_file_in_namelist(self, namelist, target_suffix):
|
|
"""Find a file in archive namelist by suffix (case-insensitive)."""
|
|
target_lower = target_suffix.lower()
|
|
for name in namelist:
|
|
norm_name = name.replace('\\', '/')
|
|
if norm_name.lower().endswith(target_lower):
|
|
return name
|
|
return None
|
|
|
|
def _find_all_files_in_namelist(self, namelist, target_suffix):
|
|
"""Find ALL files in archive namelist by suffix (case-insensitive)."""
|
|
target_lower = target_suffix.lower()
|
|
matches = []
|
|
for name in namelist:
|
|
norm_name = name.replace('\\', '/')
|
|
if norm_name.lower().endswith(target_lower):
|
|
matches.append(name)
|
|
return matches
|
|
|
|
def _format_version(self, version_str):
|
|
"""Convert version string to human-readable format."""
|
|
if not version_str or version_str == 'N/A':
|
|
return 'N/A'
|
|
|
|
version_str = str(version_str).strip()
|
|
|
|
if version_str.lower().startswith('0x'):
|
|
hex_part = version_str[2:]
|
|
if len(hex_part) >= 2:
|
|
result = hex_part[:2]
|
|
if len(result) == 2 and result[0] == '0' and result[1].isdigit():
|
|
result = result[1]
|
|
return result
|
|
return version_str
|
|
|
|
if '.' in version_str:
|
|
parts = version_str.split('.')
|
|
return parts[0]
|
|
|
|
return version_str
|
|
|
|
def _extract_game_data_from_param(self, param_data):
|
|
"""Extract required fields from param.json data."""
|
|
|
|
title_name = None
|
|
|
|
if not title_name:
|
|
title_name = param_data.get('titleName')
|
|
|
|
if not title_name:
|
|
title_obj = param_data.get('title', {})
|
|
if isinstance(title_obj, dict):
|
|
title_name = title_obj.get('en') or title_obj.get('EN') or title_obj.get('english')
|
|
if not title_name:
|
|
for key, value in title_obj.items():
|
|
if isinstance(value, str) and len(value.strip()) > 0:
|
|
title_name = value
|
|
break
|
|
|
|
if not title_name:
|
|
loc_params = param_data.get('localizedParameters', {})
|
|
if isinstance(loc_params, dict):
|
|
default_lang = loc_params.get('defaultLanguage')
|
|
if default_lang and isinstance(loc_params.get(default_lang), dict):
|
|
title_name = loc_params.get(default_lang, {}).get('titleName')
|
|
|
|
if not title_name:
|
|
for lang_key in ['en-US', 'en-GB', 'en-AU', 'en']:
|
|
if isinstance(loc_params.get(lang_key), dict):
|
|
title_name = loc_params.get(lang_key, {}).get('titleName')
|
|
if title_name:
|
|
break
|
|
|
|
if not title_name:
|
|
for lang_key, lang_data in loc_params.items():
|
|
if isinstance(lang_data, dict) and lang_key != 'defaultLanguage':
|
|
title_name = lang_data.get('titleName')
|
|
if title_name:
|
|
break
|
|
|
|
if not title_name:
|
|
title_name = "Unknown Title"
|
|
|
|
raw_title_id = param_data.get('titleId', 'N/A')
|
|
title_id = str(raw_title_id).upper() if raw_title_id else 'N/A'
|
|
|
|
return {
|
|
'titleId': title_id,
|
|
'contentVersion': param_data.get('contentVersion', 'N/A'),
|
|
'sdkVersion': self._format_version(param_data.get('sdkVersion', 'N/A')),
|
|
'requiredSystemSoftwareVersion': self._format_version(param_data.get('requiredSystemSoftwareVersion', 'N/A')),
|
|
'contentId': param_data.get('contentId', 'N/A'),
|
|
'titleName': title_name
|
|
}
|
|
|
|
def _process_icon_bytes(self, icon_bytes):
|
|
"""Process icon bytes to base64 and PIL image."""
|
|
if not icon_bytes:
|
|
return None, None
|
|
try:
|
|
img_b64 = base64.b64encode(icon_bytes).decode('utf-8')
|
|
pil_image = Image.open(io.BytesIO(icon_bytes)).resize((128, 128), Image.Resampling.LANCZOS)
|
|
return img_b64, pil_image
|
|
except Exception as e:
|
|
print(f"[ICON ERROR] Failed to process icon: {e}")
|
|
return None, None
|
|
|
|
def _scan_folder(self, folder_path):
|
|
"""Scan a folder for PS5 game param.json and icon0.png."""
|
|
folder = Path(folder_path)
|
|
sce_sys = folder / 'sce_sys'
|
|
param_file = sce_sys / 'param.json'
|
|
icon_file = sce_sys / 'icon0.png'
|
|
|
|
if not param_file.exists():
|
|
return None
|
|
|
|
try:
|
|
with open(param_file, 'r', encoding='utf-8-sig') as f:
|
|
param_data = json.load(f)
|
|
|
|
game_data = self._extract_game_data_from_param(param_data)
|
|
|
|
if game_data['titleId'] == 'N/A':
|
|
print(f"[SCAN] Skipped (Invalid Title ID): {folder_path}", flush=True)
|
|
return None
|
|
|
|
print(f"[SCAN] Found Folder Game: {game_data['titleName']} ({game_data['titleId']})", flush=True)
|
|
|
|
img_b64 = None
|
|
pil_image = None
|
|
if icon_file.exists():
|
|
try:
|
|
with open(icon_file, 'rb') as img_f:
|
|
img_b64, pil_image = self._process_icon_bytes(img_f.read())
|
|
except Exception as e:
|
|
print(f"[SCAN ICON ERROR] Failed to read {icon_file}: {e}", flush=True)
|
|
|
|
return {
|
|
'data': game_data,
|
|
'pil_image': pil_image,
|
|
'img_b64': img_b64,
|
|
'source_type': 'Normal Folder',
|
|
'source_path': str(folder),
|
|
'internal_path': ''
|
|
}
|
|
|
|
except json.JSONDecodeError as e:
|
|
print(f"[SCAN ERROR] Invalid JSON in {param_file}: {e}", flush=True)
|
|
return None
|
|
except Exception as e:
|
|
print(f"[SCAN ERROR] Failed to scan folder {folder_path}: {e}", flush=True)
|
|
return None
|
|
|
|
def _scan_archive(self, archive_path):
|
|
"""Scan an archive for PS5 games. Returns LIST of all games found."""
|
|
path = Path(archive_path)
|
|
real_type = self.detect_real_archive_type(str(path))
|
|
|
|
if not real_type:
|
|
print(f"[SCAN] Skipped (Unknown format): {path.name}", flush=True)
|
|
return []
|
|
|
|
if real_type == '7z' and not self.enable_7z_var.get():
|
|
print(f"[SCAN] Skipped (7z disabled): {path.name}", flush=True)
|
|
return []
|
|
|
|
print(f"[SCAN] Scanning {real_type.upper()} Archive: {path.name}", flush=True)
|
|
|
|
try:
|
|
results = []
|
|
source_type = f"{real_type.upper()} Archive"
|
|
|
|
if real_type == 'zip':
|
|
with zipfile.ZipFile(str(path), 'r') as zf:
|
|
namelist = zf.namelist()
|
|
print(f"[SCAN] -> ZIP contains {len(namelist)} item(s).", flush=True)
|
|
param_names = self._find_all_files_in_namelist(namelist, 'sce_sys/param.json')
|
|
print(f"[SCAN] -> Found {len(param_names)} param.json file(s).", flush=True)
|
|
|
|
for param_name in param_names:
|
|
try:
|
|
with zf.open(param_name) as f:
|
|
content = f.read().decode('utf-8-sig')
|
|
param_data = json.loads(content)
|
|
|
|
norm_name = param_name.replace('\\', '/')
|
|
parts = norm_name.split('/')
|
|
internal_path = '/'.join(parts[:-2]) if len(parts) > 2 else ''
|
|
|
|
icon_prefix = norm_name.rsplit('param.json', 1)[0]
|
|
icon_name = next((n for n in namelist if n.replace('\\', '/').lower() == (icon_prefix + 'icon0.png').lower()), None)
|
|
|
|
icon_bytes = None
|
|
if icon_name:
|
|
with zf.open(icon_name) as f:
|
|
icon_bytes = f.read()
|
|
|
|
if param_data:
|
|
game_data = self._extract_game_data_from_param(param_data)
|
|
if game_data['titleId'] != 'N/A':
|
|
img_b64, pil_image = self._process_icon_bytes(icon_bytes)
|
|
results.append({
|
|
'data': game_data, 'pil_image': pil_image, 'img_b64': img_b64,
|
|
'source_type': source_type, 'source_path': str(path),
|
|
'internal_path': internal_path
|
|
})
|
|
print(f"[SCAN] -> Found Game: {game_data['titleName']} ({game_data['titleId']})", flush=True)
|
|
except Exception as e:
|
|
print(f"[SCAN ERROR] Failed to process {param_name} in {path.name}: {e}", flush=True)
|
|
continue
|
|
|
|
elif real_type == 'rar':
|
|
try:
|
|
with rarfile.RarFile(str(path), 'r') as rf:
|
|
namelist = rf.namelist()
|
|
print(f"[SCAN] -> RAR contains {len(namelist)} item(s).", flush=True)
|
|
|
|
# ============ DEBUG: Show first 10 file paths ============
|
|
print(f"[DEBUG] Tool: {rarfile.UNRAR_TOOL}", flush=True)
|
|
for n in namelist[:10]:
|
|
print(f"[DEBUG PATH] {repr(n)}", flush=True)
|
|
# =========================================================
|
|
|
|
param_names = self._find_all_files_in_namelist(namelist, 'sce_sys/param.json')
|
|
print(f"[SCAN] -> Found {len(param_names)} param.json file(s).", flush=True)
|
|
|
|
for param_name in param_names:
|
|
try:
|
|
print(f"[DEBUG] Opening: {repr(param_name)}", flush=True)
|
|
with rf.open(param_name) as f:
|
|
content = f.read()
|
|
param_data = None
|
|
for encoding in ['utf-8-sig', 'utf-8', 'latin-1']:
|
|
try:
|
|
param_data = json.loads(content.decode(encoding))
|
|
break
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
continue
|
|
|
|
norm_name = param_name.replace('\\', '/')
|
|
parts = norm_name.split('/')
|
|
internal_path = '/'.join(parts[:-2]) if len(parts) > 2 else ''
|
|
|
|
icon_prefix = norm_name.rsplit('param.json', 1)[0]
|
|
icon_name = next((n for n in namelist if n.replace('\\', '/').lower() == (icon_prefix + 'icon0.png').lower()), None)
|
|
|
|
icon_bytes = None
|
|
if icon_name:
|
|
try:
|
|
with rf.open(icon_name) as f:
|
|
icon_bytes = f.read()
|
|
except Exception as icon_err:
|
|
print(f"[DEBUG] Icon failed: {icon_err}", flush=True)
|
|
|
|
if param_data:
|
|
game_data = self._extract_game_data_from_param(param_data)
|
|
if game_data['titleId'] != 'N/A':
|
|
img_b64, pil_image = self._process_icon_bytes(icon_bytes)
|
|
results.append({
|
|
'data': game_data, 'pil_image': pil_image, 'img_b64': img_b64,
|
|
'source_type': source_type, 'source_path': str(path),
|
|
'internal_path': internal_path
|
|
})
|
|
print(f"[SCAN] -> Found Game: {game_data['titleName']} ({game_data['titleId']})", flush=True)
|
|
except Exception as e:
|
|
print(f"[SCAN ERROR] Failed to process {param_name} in {path.name}: {e}", flush=True)
|
|
import traceback
|
|
traceback.print_exc()
|
|
continue
|
|
except Exception as e:
|
|
print(f"[SCAN ERROR] Failed to open RAR {path.name}: {e}", flush=True)
|
|
import traceback
|
|
traceback.print_exc()
|
|
return []
|
|
|
|
elif real_type == '7z':
|
|
with py7zr.SevenZipFile(str(path), 'r') as szf:
|
|
namelist = szf.getnames()
|
|
print(f"[SCAN] -> 7z contains {len(namelist)} item(s).", flush=True)
|
|
param_names = self._find_all_files_in_namelist(namelist, 'sce_sys/param.json')
|
|
print(f"[SCAN] -> Found {len(param_names)} param.json file(s).", flush=True)
|
|
icon_names = self._find_all_files_in_namelist(namelist, 'sce_sys/icon0.png')
|
|
|
|
for param_name in param_names:
|
|
try:
|
|
norm_name = param_name.replace('\\', '/')
|
|
parts = norm_name.split('/')
|
|
internal_path = '/'.join(parts[:-2]) if len(parts) > 2 else ''
|
|
|
|
icon_prefix = norm_name.rsplit('param.json', 1)[0]
|
|
icon_name = next((n for n in icon_names if n.replace('\\', '/').lower() == (icon_prefix + 'icon0.png').lower()), None)
|
|
|
|
targets = [param_name]
|
|
if icon_name: targets.append(icon_name)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
szf.extract(targets=targets, path=tmp)
|
|
|
|
param_file = Path(tmp) / param_name
|
|
param_data = None
|
|
if param_file.exists():
|
|
with open(param_file, 'r', encoding='utf-8-sig') as f:
|
|
param_data = json.load(f)
|
|
|
|
icon_bytes = None
|
|
if icon_name:
|
|
icon_file = Path(tmp) / icon_name
|
|
if icon_file.exists():
|
|
with open(icon_file, 'rb') as f:
|
|
icon_bytes = f.read()
|
|
|
|
if param_data:
|
|
game_data = self._extract_game_data_from_param(param_data)
|
|
if game_data['titleId'] != 'N/A':
|
|
img_b64, pil_image = self._process_icon_bytes(icon_bytes)
|
|
results.append({
|
|
'data': game_data, 'pil_image': pil_image, 'img_b64': img_b64,
|
|
'source_type': source_type, 'source_path': str(path),
|
|
'internal_path': internal_path
|
|
})
|
|
print(f"[SCAN] -> Found Game: {game_data['titleName']} ({game_data['titleId']})", flush=True)
|
|
except Exception as e:
|
|
print(f"[SCAN ERROR] Failed to process {param_name} in {path.name}: {e}", flush=True)
|
|
continue
|
|
|
|
return results
|
|
|
|
except Exception as e:
|
|
print(f"[SCAN ERROR] Failed to scan archive {path.name}: {e}", flush=True)
|
|
return []
|
|
|
|
def _scan_single_item(self, task_type, task_path, ignore_cache):
|
|
"""Scan a single folder or archive for PS5 game data. Returns LIST of results."""
|
|
results = []
|
|
|
|
if task_type == 'folder':
|
|
cache_key = f"{task_path}||"
|
|
|
|
if not ignore_cache and cache_key in self.games_cache:
|
|
cached = self.games_cache[cache_key]
|
|
if cached.get('data', {}).get('titleId') != 'N/A':
|
|
pil_image = None
|
|
if cached.get('img_b64'):
|
|
try:
|
|
pil_image = Image.open(io.BytesIO(base64.b64decode(cached['img_b64']))).resize((128, 128), Image.Resampling.LANCZOS)
|
|
except:
|
|
pass
|
|
results.append({
|
|
'data': cached['data'],
|
|
'pil_image': pil_image,
|
|
'img_b64': cached.get('img_b64'),
|
|
'source_type': cached.get('source_type', 'Normal Folder'),
|
|
'source_path': task_path,
|
|
'internal_path': ''
|
|
})
|
|
return results
|
|
|
|
result = self._scan_folder(task_path)
|
|
if result:
|
|
results.append(result)
|
|
return results
|
|
|
|
elif task_type == 'archive':
|
|
# For archives, check cache first for all games from this archive
|
|
if not ignore_cache:
|
|
cached_results = []
|
|
for cache_key, cached in self.games_cache.items():
|
|
# Check if this cache entry belongs to this archive
|
|
if cache_key.startswith(f"{task_path}||") and cache_key != f"{task_path}||":
|
|
if cached.get('data', {}).get('titleId') != 'N/A':
|
|
pil_image = None
|
|
if cached.get('img_b64'):
|
|
try:
|
|
pil_image = Image.open(io.BytesIO(base64.b64decode(cached['img_b64']))).resize((128, 128), Image.Resampling.LANCZOS)
|
|
except:
|
|
pass
|
|
_, internal_path = cache_key.split("||", 1)
|
|
cached_results.append({
|
|
'data': cached['data'],
|
|
'pil_image': pil_image,
|
|
'img_b64': cached.get('img_b64'),
|
|
'source_type': cached.get('source_type', ''),
|
|
'source_path': task_path,
|
|
'internal_path': internal_path
|
|
})
|
|
|
|
if cached_results:
|
|
return cached_results
|
|
|
|
# Not in cache or ignoring cache - scan the archive
|
|
return self._scan_archive(task_path)
|
|
|
|
return []
|
|
|
|
def _deduplicate_games(self, results):
|
|
"""Remove duplicates, prioritizing archives over folders (except 7z)."""
|
|
def get_priority(source_type):
|
|
st = source_type.upper()
|
|
if 'ZIP' in st: return 1
|
|
elif 'RAR' in st: return 2
|
|
elif '7Z' in st: return 4
|
|
else: return 3
|
|
|
|
games_by_title = {}
|
|
for result in results:
|
|
title_id = result['data']['titleId']
|
|
if title_id not in games_by_title:
|
|
games_by_title[title_id] = []
|
|
games_by_title[title_id].append(result)
|
|
|
|
final_results = []
|
|
for title_id, games in games_by_title.items():
|
|
sorted_games = sorted(games, key=lambda g: get_priority(g['source_type']))
|
|
final_results.append(sorted_games[0])
|
|
|
|
return final_results
|
|
|
|
def _scan_worker(self, root_path, ignore_cache=False):
|
|
"""Worker thread that scans for PS5 games in folders and archives."""
|
|
print(f"\n[SCAN] ========================================", flush=True)
|
|
print(f"[SCAN] Starting scan: {root_path}", flush=True)
|
|
print(f"[SCAN] Ignore cache: {ignore_cache}", flush=True)
|
|
try:
|
|
self._show_scan_progress()
|
|
self._thread_safe_status("Scanning...", "yellow")
|
|
|
|
max_threads = int(self.max_scan_threads_var.get())
|
|
valid_archive_exts = {'.zip', '.rar', '.7z'}
|
|
|
|
root = Path(root_path)
|
|
scan_tasks = []
|
|
|
|
# 1. Find folders
|
|
print(f"[SCAN] Searching for game folders...", flush=True)
|
|
for sce_sys_dir in root.rglob('sce_sys'):
|
|
if any(part.startswith('.') or part.startswith('@') for part in sce_sys_dir.parts):
|
|
continue
|
|
if sce_sys_dir.is_dir():
|
|
param_file = sce_sys_dir / 'param.json'
|
|
if param_file.exists():
|
|
game_folder = sce_sys_dir.parent
|
|
scan_tasks.append(('folder', str(game_folder)))
|
|
print(f"[SCAN] Found {len([t for t in scan_tasks if t[0] == 'folder'])} folder(s).", flush=True)
|
|
|
|
# 2. Find archives
|
|
print(f"[SCAN] Searching for archives...", flush=True)
|
|
for archive_path in root.rglob('*'):
|
|
if any(part.startswith('.') or part.startswith('@') for part in archive_path.parts):
|
|
continue
|
|
if archive_path.is_file() and archive_path.suffix.lower() in valid_archive_exts:
|
|
scan_tasks.append(('archive', str(archive_path)))
|
|
print(f"[SCAN] Found {len([t for t in scan_tasks if t[0] == 'archive'])} archive(s).", flush=True)
|
|
|
|
total_tasks = len(scan_tasks)
|
|
|
|
if total_tasks == 0:
|
|
print(f"[SCAN] No items found to scan.", flush=True)
|
|
self._thread_safe_status("No games found.", "gray")
|
|
return
|
|
|
|
self._thread_safe_status(f"Scanning {total_tasks} items...", "yellow")
|
|
|
|
# Process tasks
|
|
all_results = []
|
|
completed = 0
|
|
|
|
with ThreadPoolExecutor(max_workers=max_threads) as executor:
|
|
future_to_task = {
|
|
executor.submit(self._scan_single_item, task_type, task_path, ignore_cache): (task_type, task_path)
|
|
for task_type, task_path in scan_tasks
|
|
}
|
|
|
|
for future in as_completed(future_to_task):
|
|
try:
|
|
results = future.result()
|
|
if results:
|
|
all_results.extend(results)
|
|
except Exception as e:
|
|
task_type, task_path = future_to_task[future]
|
|
print(f"[SCAN ERROR] {task_type} {task_path}: {e}", flush=True)
|
|
|
|
completed += 1
|
|
self._update_scan_progress(completed / total_tasks)
|
|
|
|
# Deduplicate
|
|
deduplicated_results = self._deduplicate_games(all_results)
|
|
|
|
# Add to GUI
|
|
added_count = 0
|
|
for result in deduplicated_results:
|
|
status = self._process_result(
|
|
result['data'], result['pil_image'], result['source_type'],
|
|
result['source_path'], result.get('internal_path', '')
|
|
)
|
|
if status == 'success':
|
|
added_count += 1
|
|
cache_key = f"{result['source_path']}||{result.get('internal_path', '')}"
|
|
self.games_cache[cache_key] = {
|
|
'data': result['data'],
|
|
'img_b64': result.get('img_b64'),
|
|
'source_type': result['source_type']
|
|
}
|
|
|
|
self._save_games_cache()
|
|
duplicate_count = len(all_results) - len(deduplicated_results)
|
|
|
|
print(f"[SCAN] Total raw results: {len(all_results)}", flush=True)
|
|
print(f"[SCAN] Duplicates hidden: {duplicate_count}", flush=True)
|
|
print(f"[SCAN] Games added to GUI: {added_count}", flush=True)
|
|
print(f"[SCAN] ========================================\n", flush=True)
|
|
|
|
if added_count > 0:
|
|
msg = f"Found {added_count} game(s)."
|
|
if duplicate_count > 0:
|
|
msg += f" ({duplicate_count} duplicate(s) hidden)"
|
|
self._thread_safe_status(msg, "green")
|
|
else:
|
|
self._thread_safe_status("No valid games found.", "orange")
|
|
|
|
except Exception as e:
|
|
print(f"[SCAN ERROR] {e}", flush=True)
|
|
import traceback
|
|
traceback.print_exc()
|
|
self._thread_safe_status(f"Scan error: {str(e)}", "red")
|
|
|
|
finally:
|
|
def finish():
|
|
self.browse_btn.configure(state="normal")
|
|
self.refresh_btn.configure(state="normal")
|
|
self.is_scanning = False
|
|
self.after(500, self._hide_scan_progress)
|
|
self.after(0, finish)
|
|
|
|
# --- Scanning & GUI Populating ---
|
|
def load_from_cache_on_startup(self):
|
|
if not self.games_cache:
|
|
self.status_label.configure(text="No cache found.", text_color="gray")
|
|
return
|
|
|
|
self.clear_list()
|
|
self.status_label.configure(text="Loading cache...", text_color="yellow")
|
|
self.update_idletasks()
|
|
|
|
allow_7z = self.app_settings.get("enable_7z", False)
|
|
total = len(self.games_cache)
|
|
self._show_scan_progress()
|
|
|
|
# Collect all cached results first
|
|
all_cached = []
|
|
for i, (key, entry) in enumerate(self.games_cache.items()):
|
|
sp, ip = key.split("||", 1) if "||" in key else (key, "")
|
|
gd = entry.get("data")
|
|
if not gd or gd.get("titleId") == "N/A":
|
|
continue
|
|
if not allow_7z and ".7Z" in entry.get("source_type", ""):
|
|
continue
|
|
|
|
pil = None
|
|
if entry.get("img_b64"):
|
|
try:
|
|
pil = Image.open(io.BytesIO(base64.b64decode(entry["img_b64"]))).resize((128, 128), Image.Resampling.LANCZOS)
|
|
except:
|
|
pass
|
|
|
|
all_cached.append({
|
|
'data': gd,
|
|
'pil_image': pil,
|
|
'img_b64': entry.get('img_b64'),
|
|
'source_type': entry.get("source_type", "Normal Folder"),
|
|
'source_path': sp,
|
|
'internal_path': ip
|
|
})
|
|
|
|
self._update_scan_progress((i + 1) / total if total > 0 else 1)
|
|
|
|
# DEDUPLICATE cached results
|
|
deduplicated = self._deduplicate_games(all_cached)
|
|
|
|
# Add deduplicated results to GUI
|
|
loaded = 0
|
|
for result in deduplicated:
|
|
self._process_result(
|
|
result['data'],
|
|
result['pil_image'],
|
|
result['source_type'],
|
|
result['source_path'],
|
|
result.get('internal_path', '')
|
|
)
|
|
loaded += 1
|
|
|
|
# Count hidden duplicates
|
|
duplicate_count = len(all_cached) - len(deduplicated)
|
|
|
|
msg = f"Loaded {loaded} games from cache."
|
|
if duplicate_count > 0:
|
|
msg += f" ({duplicate_count} duplicate(s) hidden)"
|
|
|
|
self.status_label.configure(text=msg, text_color="green")
|
|
self.after(500, self._hide_scan_progress)
|
|
|
|
def _periodic_sync_loop(self):
|
|
"""Background loop that checks for new games every 5 seconds."""
|
|
while True:
|
|
time.sleep(5)
|
|
try:
|
|
# FIX: Thread safety check using boolean flag instead of cget
|
|
if self.is_scanning:
|
|
continue
|
|
|
|
last_folder = self.app_settings.get("latest_folder")
|
|
if not last_folder or not os.path.exists(last_folder):
|
|
continue
|
|
|
|
if self._check_for_new_items(last_folder):
|
|
self._run_incremental_sync(last_folder)
|
|
except Exception:
|
|
# Ignore transient errors
|
|
pass
|
|
|
|
def _check_for_new_items(self, root_path):
|
|
"""Quickly check if there are any new folders or archives without doing a heavy scan."""
|
|
root = Path(root_path)
|
|
valid_archive_exts = {'.zip', '.rar', '.7z'}
|
|
|
|
# 1. Check folders
|
|
for sce_sys_dir in root.rglob('sce_sys'):
|
|
if any(part.startswith('.') or part.startswith('@') for part in sce_sys_dir.parts):
|
|
continue
|
|
if sce_sys_dir.is_dir():
|
|
param_file = sce_sys_dir / 'param.json'
|
|
if param_file.exists():
|
|
game_folder = sce_sys_dir.parent
|
|
cache_key = f"{game_folder}||"
|
|
if cache_key not in self.games_cache:
|
|
return True # Found a new folder
|
|
|
|
# 2. Check archives
|
|
for archive_path in root.rglob('*'):
|
|
if any(part.startswith('.') or part.startswith('@') for part in archive_path.parts):
|
|
continue
|
|
if archive_path.is_file() and archive_path.suffix.lower() in valid_archive_exts:
|
|
archive_prefix = f"{archive_path}||"
|
|
has_cached_games = any(k.startswith(archive_prefix) and k != archive_prefix for k in self.games_cache.keys())
|
|
|
|
if not has_cached_games:
|
|
real_type = self.detect_real_archive_type(str(archive_path))
|
|
if real_type == '7z' and not self.enable_7z_var.get():
|
|
continue
|
|
return True # Found a new archive
|
|
|
|
return False
|
|
|
|
def _run_incremental_sync(self, root_path):
|
|
"""Scan and add only the newly detected games to the GUI."""
|
|
print(f"\n[AUTO-SYNC] New files detected, running incremental scan...", flush=True)
|
|
root = Path(root_path)
|
|
new_results = []
|
|
valid_archive_exts = {'.zip', '.rar', '.7z'}
|
|
|
|
try:
|
|
for sce_sys_dir in root.rglob('sce_sys'):
|
|
if any(part.startswith('.') or part.startswith('@') for part in sce_sys_dir.parts):
|
|
continue
|
|
if sce_sys_dir.is_dir():
|
|
param_file = sce_sys_dir / 'param.json'
|
|
if param_file.exists():
|
|
game_folder = sce_sys_dir.parent
|
|
cache_key = f"{game_folder}||"
|
|
if cache_key not in self.games_cache:
|
|
result = self._scan_folder(str(game_folder))
|
|
if result:
|
|
new_results.append(result)
|
|
|
|
for archive_path in root.rglob('*'):
|
|
if any(part.startswith('.') or part.startswith('@') for part in archive_path.parts):
|
|
continue
|
|
if archive_path.is_file() and archive_path.suffix.lower() in valid_archive_exts:
|
|
archive_prefix = f"{archive_path}||"
|
|
has_cached_games = any(k.startswith(archive_prefix) and k != archive_prefix for k in self.games_cache.keys())
|
|
|
|
if not has_cached_games:
|
|
real_type = self.detect_real_archive_type(str(archive_path))
|
|
if real_type == '7z' and not self.enable_7z_var.get():
|
|
continue
|
|
results = self._scan_archive(str(archive_path))
|
|
new_results.extend(results)
|
|
|
|
# FIX: Save an empty/failed marker to cache so we don't spam scan it every 5 seconds
|
|
if not results:
|
|
self.games_cache[f"{archive_path}||__SCAN_FAILED__"] = {
|
|
'data': {'titleId': 'N/A'},
|
|
'source_type': 'Failed Scan'
|
|
}
|
|
|
|
if not new_results:
|
|
print(f"[AUTO-SYNC] No new valid games found.", flush=True)
|
|
return
|
|
|
|
deduplicated = self._deduplicate_games(new_results)
|
|
|
|
added_count = 0
|
|
for result in deduplicated:
|
|
status = self._process_result(
|
|
result['data'], result['pil_image'], result['source_type'],
|
|
result['source_path'], result.get('internal_path', '')
|
|
)
|
|
if status == 'success':
|
|
added_count += 1
|
|
cache_key = f"{result['source_path']}||{result.get('internal_path', '')}"
|
|
self.games_cache[cache_key] = {
|
|
'data': result['data'],
|
|
'img_b64': result.get('img_b64'),
|
|
'source_type': result['source_type']
|
|
}
|
|
|
|
if added_count > 0:
|
|
self._save_games_cache()
|
|
print(f"[AUTO-SYNC] Auto-added {added_count} game(s).", flush=True)
|
|
self._thread_safe_status(f"Auto-added {added_count} new game(s).", "green")
|
|
else:
|
|
print(f"[AUTO-SYNC] Games found, but skipped as duplicates.", flush=True)
|
|
self._thread_safe_status("Up to date.", "green")
|
|
|
|
except Exception as e:
|
|
print(f"[AUTO-SYNC ERROR] {e}", flush=True)
|
|
|
|
def save_settings(self, folder_path):
|
|
self._on_settings_changed(); self.app_settings["latest_folder"] = folder_path
|
|
try:
|
|
with open(self.settings_file, 'w', encoding='utf-8') as f: json.dump(self.app_settings, f, indent=4)
|
|
except: pass
|
|
|
|
def browse_folder(self):
|
|
p = filedialog.askdirectory(title="Select Root Folder", initialdir=self.app_settings.get("latest_folder", str(Path.home())))
|
|
if p: self.save_settings(p); self.scan_folders_recursively(p)
|
|
|
|
def force_refresh_cache(self):
|
|
lf = self.app_settings.get("latest_folder")
|
|
if not lf or lf in [str(Path.home()), "C:\\", "/"]: messagebox.showinfo("Info", "No folder selected."); return
|
|
if messagebox.askyesno("Refresh", "Rescan everything?"):
|
|
self.clear_list(); self.scan_folders_recursively(lf, ignore_cache=True)
|
|
|
|
def scan_folders_recursively(self, root_path, ignore_cache=False):
|
|
self.clear_list(); self.browse_btn.configure(state="disabled"); self.refresh_btn.configure(state="disabled")
|
|
self.is_scanning = True
|
|
self._thread_safe_status("Starting scan...", "yellow")
|
|
threading.Thread(target=self._scan_worker, args=(root_path, ignore_cache), daemon=True).start()
|
|
|
|
def _process_result(self, game_data, pil_image, source_type, source_path, internal_path=""):
|
|
if not game_data or game_data.get("titleId") == "N/A": return "na"
|
|
|
|
# Use composite key for deduplication: source_path||internal_path
|
|
dedupe_key = f"{source_path}||{internal_path}"
|
|
|
|
with self.seen_game_keys_lock:
|
|
if dedupe_key in self.seen_game_keys: return "dup"
|
|
self.seen_game_keys.add(dedupe_key)
|
|
|
|
self._thread_safe_add(game_data, pil_image, source_type, source_path, internal_path)
|
|
return "success"
|
|
|
|
def clear_list(self):
|
|
self.instr_label.pack(pady=(10, 5)); self.browse_btn.pack_forget(); self.refresh_btn.pack_forget(); self.scan_progress_frame.pack_forget()
|
|
for w in self.scrollable_frame.winfo_children(): w.destroy()
|
|
self.seen_game_keys.clear(); self.game_widgets.clear()
|
|
if self.view_mode_var.get() == "Grid": self.scrollable_frame.grid_columnconfigure((0, 1, 2), weight=1)
|
|
|
|
def add_to_gui(self, game_data, pil_image, source_type, source_path, internal_path=""):
|
|
widget_key = f"{source_path}||{internal_path}"
|
|
|
|
mode = "grid" if self.view_mode_var.get() == "Grid" else "list"
|
|
cols = 3 if mode == "grid" else 1
|
|
index = len(self.game_widgets)
|
|
row, col = index // cols, index % cols
|
|
|
|
self.game_widgets[widget_key] = {
|
|
"progress": None, "status_label": None, "btn_start": None,
|
|
"fakelib_var": None, "fakelib_all_var": None, "fakelib_menu": None,
|
|
"time_label": None, "progress_frame": None, "data": game_data,
|
|
"is_backported": False, "pil_image": pil_image, "source_path": source_path,
|
|
"source_type": source_type, "internal_path": internal_path, "is_processing": False
|
|
}
|
|
|
|
p, s, b, fv, fav, fm, t, pf = self.create_list_item(
|
|
game_data, pil_image, source_type, widget_key,
|
|
mode=mode, row=row, col=col, internal_path=internal_path
|
|
)
|
|
|
|
title_id = game_data["titleId"]
|
|
is_backported, _ = self.check_backport_status(
|
|
game_data["titleName"], title_id, game_data["contentVersion"]
|
|
)
|
|
self.game_widgets[widget_key].update({
|
|
"progress": p, "status_label": s, "btn_start": b, "fakelib_var": fv,
|
|
"fakelib_all_var": fav, "fakelib_menu": fm, "time_label": t,
|
|
"progress_frame": pf, "is_backported": is_backported
|
|
})
|
|
|
|
if len(self.game_widgets) == 1:
|
|
self.instr_label.pack_forget()
|
|
self.browse_btn.pack(side="left", padx=5)
|
|
self.refresh_btn.pack(side="left", padx=5)
|
|
|
|
def create_list_item(self, data, pil_image, source_type, widget_key, mode="list", row=0, col=0, internal_path=""):
|
|
if internal_path:
|
|
if len(internal_path) > 40:
|
|
display_path = "..." + internal_path[-37:]
|
|
else:
|
|
display_path = internal_path
|
|
internal_path_label = f" {display_path}"
|
|
else:
|
|
internal_path_label = ""
|
|
|
|
if mode == "grid":
|
|
cell = ctk.CTkFrame(self.scrollable_frame); cell.grid(row=row, column=col, padx=10, pady=10, sticky="nsew")
|
|
pf = ctk.CTkFrame(cell, fg_color="transparent"); pf.pack(side="bottom", fill="x", padx=5, pady=(0, 5))
|
|
pb = ctk.CTkProgressBar(pf, height=8, corner_radius=4); pb.pack(side="left", fill="x", expand=True, padx=(0, 5)); pb.set(0)
|
|
tl = ctk.CTkLabel(pf, text="", font=("Consolas", 11), text_color="gray", width=45, anchor="e"); tl.pack(side="right")
|
|
if pil_image:
|
|
try: ctk.CTkLabel(cell, image=ctk.CTkImage(light_image=pil_image, dark_image=pil_image, size=(128, 128)), text="").pack(pady=(10, 5))
|
|
except: pass
|
|
ctk.CTkLabel(cell, text=data["titleName"], font=("Arial", 14, "bold"), wraplength=180, justify="center").pack(pady=2)
|
|
ctk.CTkLabel(cell, text=f"{data['titleId']} | FW: {data['requiredSystemSoftwareVersion']}", font=("Arial", 11), text_color="gray", justify="center", wraplength=180).pack(pady=(0, 5))
|
|
if internal_path_label:
|
|
ctk.CTkLabel(cell, text=internal_path_label, font=("Arial", 10), text_color="#888888", justify="center", wraplength=180).pack(pady=(0, 5))
|
|
is_bp, bp_info = self.check_backport_status(data["titleName"], data["titleId"], data["contentVersion"])
|
|
if is_bp:
|
|
stxt = f"Backported ({', '.join(bp_info)})" if bp_info else "Backported"
|
|
scol = "#00FF00"
|
|
else:
|
|
stxt = "Not Backported"
|
|
scol = "#FF0000"
|
|
sl = ctk.CTkLabel(cell, text=stxt, font=("Arial", 12, "bold"), text_color=scol); sl.pack(pady=5)
|
|
opts = self.get_available_fakelib_versions(); fv = tk.StringVar(value=opts[0] if opts else "None"); fav = tk.BooleanVar(value=False)
|
|
fm = ctk.CTkOptionMenu(cell, variable=fv, values=opts, width=100); fm.pack(pady=(5, 2))
|
|
ctk.CTkCheckBox(cell, text="All SDKs", variable=fav).pack(pady=2)
|
|
bf = ctk.CTkFrame(cell, fg_color="transparent"); bf.pack(pady=(5, 10))
|
|
b = ctk.CTkButton(bf, text="Start", width=80, height=28, command=lambda wk=widget_key: self.start_single_backport(wk)); b.pack(side="left", padx=2)
|
|
ctk.CTkButton(bf, text="Open", width=60, height=28, fg_color="transparent", border_width=1, command=lambda wk=widget_key: self.open_backport_folder(wk)).pack(side="left", padx=2)
|
|
else:
|
|
row_f = ctk.CTkFrame(self.scrollable_frame); row_f.pack(fill="x", pady=5, padx=5)
|
|
pf = ctk.CTkFrame(row_f, fg_color="transparent"); pf.pack_forget()
|
|
pb = ctk.CTkProgressBar(pf, height=8, corner_radius=4); pb.pack(side="left", fill="x", expand=True, padx=(10, 5), pady=5); pb.set(0)
|
|
tl = ctk.CTkLabel(pf, text="", font=("Consolas", 11), text_color="gray", width=45, anchor="e"); tl.pack(side="right", padx=(0, 10), pady=5)
|
|
if pil_image:
|
|
try: ctk.CTkLabel(row_f, image=ctk.CTkImage(light_image=pil_image, dark_image=pil_image, size=(64, 64)), text="").pack(side="left", padx=10, pady=10)
|
|
except: pass
|
|
txt_f = ctk.CTkFrame(row_f, fg_color="transparent"); txt_f.pack(side="left", fill="x", expand=True, pady=10)
|
|
ctk.CTkLabel(txt_f, text=data["titleName"], font=("Arial", 16, "bold"), anchor="w").pack(fill="x")
|
|
info_text = f"Title ID: {data['titleId']}\nVersion: {data['contentVersion']} | FW: {data['requiredSystemSoftwareVersion']} | SDK: {data['sdkVersion']}\nContent ID: {data['contentId']}\nSource: {source_type}"
|
|
if internal_path:
|
|
info_text += f"\nArchive Path: {internal_path}"
|
|
ctk.CTkLabel(txt_f, text=info_text, font=("Arial", 12), anchor="w", justify="left").pack(fill="x")
|
|
is_bp, bp_info = self.check_backport_status(data["titleName"], data["titleId"], data["contentVersion"])
|
|
if is_bp:
|
|
stxt = f"Backported ({', '.join(bp_info)})" if bp_info else "Backported"
|
|
scol = "#00FF00"
|
|
else:
|
|
stxt = "Not Backported"
|
|
scol = "#FF0000"
|
|
sl = ctk.CTkLabel(txt_f, text=stxt, font=("Arial", 14, "bold"), text_color=scol, anchor="w"); sl.pack(fill="x", pady=(5, 0))
|
|
btn_f = ctk.CTkFrame(row_f, fg_color="transparent"); btn_f.pack(side="right", padx=10, fill="y")
|
|
opts = self.get_available_fakelib_versions(); fv = tk.StringVar(value=opts[0] if opts else "None"); fav = tk.BooleanVar(value=False)
|
|
fm = ctk.CTkOptionMenu(btn_f, variable=fv, values=opts, width=80); fm.pack(pady=(10, 2))
|
|
ctk.CTkCheckBox(btn_f, text="All SDKs", variable=fav, width=80).pack(pady=(0, 2))
|
|
b = ctk.CTkButton(btn_f, text="Start", width=80, height=28, command=lambda wk=widget_key: self.start_single_backport(wk)); b.pack(pady=(2, 2))
|
|
ctk.CTkButton(btn_f, text="Open Dir", width=80, height=28, fg_color="transparent", border_width=1, command=lambda wk=widget_key: self.open_backport_folder(wk)).pack(pady=(2, 10))
|
|
return pb, sl, b, fv, fav, fm, tl, pf
|
|
|
|
def _load_app_settings(self):
|
|
if self.config_dir.joinpath("settings.json").exists():
|
|
try:
|
|
with open(self.config_dir / "settings.json", 'r', encoding='utf-8') as f: return json.load(f)
|
|
except: pass
|
|
return {
|
|
"latest_folder": str(Path.home()),
|
|
"custom_output_dir": "",
|
|
"enable_7z": False,
|
|
"appearance_mode": "dark",
|
|
"color_theme": "blue",
|
|
"max_scan_threads": 4,
|
|
"max_backport_threads": 4,
|
|
"view_mode": "List"
|
|
}
|
|
|
|
def _load_games_cache(self):
|
|
if self.config_dir.joinpath("games.json").exists():
|
|
try:
|
|
with open(self.config_dir / "games.json", 'r', encoding='utf-8') as f: return json.load(f)
|
|
except: pass
|
|
return {}
|
|
|
|
def _save_games_cache(self):
|
|
try:
|
|
with open(self.cache_file, 'w', encoding='utf-8') as f: json.dump(self.games_cache, f, indent=4)
|
|
except: pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app = App()
|
|
app.mainloop() |