diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20cb9d5..6b8e69a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,8 @@ on: permissions: contents: write + id-token: write + attestations: write jobs: validate: @@ -30,6 +32,14 @@ jobs: python tests.py python integration_tests.py + - name: Generate software bill of materials + run: python scripts/generate_sbom.py + + - uses: actions/upload-artifact@v4 + with: + name: release-sbom + path: UnityScraper-SBOM.cdx.json + windows: runs-on: windows-latest needs: validate @@ -52,7 +62,7 @@ jobs: shell: pwsh run: | New-Item -ItemType Directory -Path package | Out-Null - Copy-Item dist\UnityScraper.exe, README.md, CHANGELOG.md, LICENSE package\ + Copy-Item dist\UnityScraper.exe, README.md, CHANGELOG.md, LICENSE, DOCS_INDEX.md, BACKUP_MANAGER.md, COLLECTION_INTELLIGENCE.md, CONSOLE_SYNC.md, KNOWLEDGE_SOURCES.md, LINUX.md, PLUGIN_API.md package\ Compress-Archive -Path package\* -DestinationPath UnityScraper-Windows-x64.zip $hash = (Get-FileHash UnityScraper-Windows-x64.zip -Algorithm SHA256).Hash.ToLower() "$hash *UnityScraper-Windows-x64.zip" | Set-Content UnityScraper-Windows-x64.zip.sha256 @@ -103,7 +113,7 @@ jobs: publish: runs-on: ubuntu-latest - needs: [windows, linux] + needs: [validate, windows, linux] steps: - uses: actions/download-artifact@v4 @@ -112,6 +122,11 @@ jobs: path: release merge-multiple: true + - name: Attest release artifacts + uses: actions/attest-build-provenance@v2 + with: + subject-path: "release/*" + - name: Publish GitHub release env: GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index 72f91d8..16fc817 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ build/ dist/ *.spec.bak *.zip +UnityScraper-SBOM.cdx.json # IDE and operating system .vs/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b54d111..f35caad 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -19,6 +19,8 @@ for CLI and optional REST automation. SQLite is the durable local store. - `modern_gui.py` builds the dark navigation shell and core pages. - `knowledge_gui.py` renders knowledge search, imports, sources, and conflicts. - `backup_gui.py` renders inventory, package, FTP, and converter workflows. +- `collection_gui.py` renders collection analysis, matching, reports, and + repair previews. - `setup_wizard.py` handles first-run storage setup. GUI operations that can block are dispatched to background threads and return @@ -31,12 +33,18 @@ results to Tk's main loop. - `backup_service.py` coordinates scans, installs, exports, verification, FTP, and audit records. - `knowledge_sync.py` exposes complete source-import workflows to CLI and GUI. +- `collection_intelligence.py` coordinates snapshots, exact MediaID matching, + health, preservation matching, repair previews, and offline exports. +- `console_sync.py` owns durable transfer jobs, resumable FTP, remote + snapshots, and PC/console comparisons. +- `database_migrations.py` applies additive schema versions and provides + consistent SQLite backup/restore helpers. ### Domain and Adapters - `main.py` contains the XboxUnity collector and shared configuration. - `resume.py` handles partial download state and verification. -- `backup_manager.py` parses public STFS/XBE fields and performs safe +- `backup_manager.py` parses public STFS/XBE/XEX fields and performs safe filesystem or FTP operations. - `knowledge_base.py` defines normalized knowledge records and resolution. - `consolemods_adapters.py`, `wiki_adapters.py`, and `dat_adapters.py` parse diff --git a/BACKUP_MANAGER.md b/BACKUP_MANAGER.md index 4a42e5d..68040ed 100644 --- a/BACKUP_MANAGER.md +++ b/BACKUP_MANAGER.md @@ -57,13 +57,15 @@ structural health check, not a substitute for Redump or No-Intro verification. ## FTP console transfer The Console Transfer tab supports a user-configured FTP server such as Aurora. -It uses one connection per operation and uploads to a temporary remote name -before renaming the completed file. The default content root is: +It persists resumable upload and download jobs, retains partial data, recovers +interrupted jobs, limits bandwidth, verifies final sizes, and can capture a +read-only console inventory. The default content root is: `/Hdd1/Content/0000000000000000` FTP passwords remain in memory and are deliberately omitted from SQLite. Traditional FTP is unencrypted, so use it only on a trusted local network. +See [CONSOLE_SYNC.md](CONSOLE_SYNC.md) for queue and comparison behavior. ## ISO conversion diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa8ffb..8f004e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ Notable changes to UnityScraper are documented here. The project follows ### Added +- Versioned additive migrations for collection snapshots, preservation + matches, repair plans, console inventories, resumable jobs, overrides, and + recovery state. +- XEX2 identity parsing, mounted-storage discovery, and immutable read-only + Aurora database import. +- Exact MediaID Title Update comparison, collection health scoring, and + non-destructive repair-plan previews. +- Redump/No-Intro file matching, offline HTML reports, manifests, and + provenance exports. +- Persistent resumable FTP upload/download jobs, bandwidth limits, transfer + verification, and read-only console snapshots. +- Database backups, plugin API v1, Italian and Portuguese translation + foundations, UI scaling, and keyboard navigation. +- CycloneDX SBOM generation and GitHub build-provenance attestations. - Native Linux desktop support with XDG data, configuration, cache, and state directories. - Linux x86_64 release bundle with user-level installation, application-menu @@ -21,6 +35,10 @@ Notable changes to UnityScraper are documented here. The project follows ### Changed +- Version advanced to `1.0.0-beta.1`. +- Download queues now use atomic writes and recover interrupted items. +- Update checks select a platform artifact and require its SHA-256 sidecar + before staging it. - PyInstaller configuration and desktop entry point now support Windows and Linux from the same source tree. - Knowledge source snapshots use the platform cache directory. diff --git a/COLLECTION_INTELLIGENCE.md b/COLLECTION_INTELLIGENCE.md new file mode 100644 index 0000000..d40eb22 --- /dev/null +++ b/COLLECTION_INTELLIGENCE.md @@ -0,0 +1,51 @@ +# Collection Intelligence + +UnityScraper 1.0 beta inventories Xbox 360 collections, retains snapshots in +SQLite, compares TitleIDs and MediaIDs with catalogued Title Updates, and +produces preservation-oriented reports. + +## Sources and Identification + +The **Collections** workspace accepts content trees, extracted `Games` +directories, mounted USB/archive folders, and user-selected Aurora SQLite +databases. Aurora databases are opened in immutable read-only mode. + +Mounted-storage discovery checks Windows drive roots, Linux `/media`, +`/run/media`, and `/mnt`, and macOS `/Volumes`. XEX2 parsing reads the public +header fields for TitleID, MediaID, versions, module flags, and disc position. +It does not decrypt or extract executable content. + +Title Update status is conservative: + +- `compatible`: TitleID and MediaID both match +- `media-id-required`: updates exist but the collection MediaID is unknown +- `incompatible`: the TitleID exists but no MediaID matches +- `none`: no update is catalogued +- `unknown`: the TitleID could not be identified + +## Preservation and Repair + +Files can be hashed with CRC32, MD5, SHA-1, and SHA-256 and matched against +user-imported Redump or No-Intro DAT metadata. UnityScraper stores metadata, +hashes, and matches, never game content. + +Exports include JSON manifests, offline HTML collection reports, and fact +provenance with sources and citations. Metadata overrides are stored +separately from imported facts. + +A repair plan is a preview in `repair_plans` and `repair_actions`. Creating +one does not delete, replace, download, or transfer anything. + +## Command Line + +```powershell +python main.py --analyze-collection D:\Xbox360 ` + --collection-manifest collection.json ` + --collection-html collection.html ` + --create-repair-plan + +python main.py --aurora-db content.db --collection-manifest aurora.json +python main.py --match-file game.iso +python main.py --export-provenance provenance.json +python main.py --backup-database unityscraper-backup.db +``` diff --git a/CONSOLE_SYNC.md b/CONSOLE_SYNC.md new file mode 100644 index 0000000..788098e --- /dev/null +++ b/CONSOLE_SYNC.md @@ -0,0 +1,34 @@ +# Console Sync + +Console Sync provides a persistent FTP queue for consoles and dashboards +whose FTP server the user explicitly configures. + +## Behavior + +- Upload and download jobs survive restarts in SQLite. +- Interrupted transfers return as paused. +- `.partial` files are retained for resume. +- FTP `REST` is used when the server supports ranged transfer. +- Uploads are published by renaming the completed partial file. +- Final sizes are verified; downloads can also require a SHA-256. +- Each job can have a bytes-per-second bandwidth limit. +- Passwords remain in memory and are never stored. + +Some console FTP servers do not implement ranged uploads correctly. Those +servers may reject resume; the job retains its state and reports the error. + +**Snapshot Console** recursively reads remote metadata without changing +files. A snapshot can be compared with a local directory to find files only +on the PC, only on the console, different-sized files, and matching files. +Discovery has a default 100,000-entry safety limit. + +```powershell +python main.py --ftp-host 192.168.1.50 --ftp-user xbox --ftp-snapshot /Hdd1 + +python main.py --ftp-host 192.168.1.50 --ftp-user xbox ` + --ftp-download /Hdd1/Content/file ` + --ftp-local-path D:\Xbox360\file ` + --ftp-bandwidth-limit 1048576 +``` + +Standard FTP is unencrypted. Use it only on a trusted local network. diff --git a/DOCS_INDEX.md b/DOCS_INDEX.md index 789a2f8..8b47006 100644 --- a/DOCS_INDEX.md +++ b/DOCS_INDEX.md @@ -5,6 +5,10 @@ - [README](README.md) - product overview, installation, core workflows, and CLI - [Backup Manager](BACKUP_MANAGER.md) - layouts, installation, exports, FTP, verification, and external conversion +- [Collection Intelligence](COLLECTION_INTELLIGENCE.md) - storage discovery, + XEX identity, Title Update compatibility, preservation, and repair previews +- [Console Sync](CONSOLE_SYNC.md) - persistent transfers, resume, snapshots, + comparison, and verification - [Knowledge Sources](KNOWLEDGE_SOURCES.md) - imports, provenance, caching, and source licensing - [Advanced Features](ADVANCED_FEATURES.md) - rate limits, resume, diagnostics, @@ -19,6 +23,7 @@ - [Architecture](ARCHITECTURE.md) - modules, layers, schemas, data flows, and packaging +- [Plugin API v1](PLUGIN_API.md) - manifests, opt-in loading, and compatibility - [Contributing](CONTRIBUTING.md) - environment, tests, PR expectations, and adapter rules - [Security](SECURITY.md) - private reporting and operational boundaries diff --git a/PLUGIN_API.md b/PLUGIN_API.md new file mode 100644 index 0000000..5b9e5f6 --- /dev/null +++ b/PLUGIN_API.md @@ -0,0 +1,29 @@ +# Plugin API v1 + +Metadata collectors use a manifest-based, opt-in API. Disabled plugin code is +discovered but never imported. + +```text +plugins/ + example/ + plugin.json + collector.py +``` + +```json +{ + "id": "org.example.collector", + "name": "Example Collector", + "version": "1.0.0", + "api_version": 1, + "entrypoint": "collector.py", + "permissions": ["network"] +} +``` + +The entrypoint exports a `MetadataCollectorPlugin` subclass. The caller must +pass the plugin ID in `enabled_plugins` before code is loaded. Permissions +are disclosure metadata, not an operating-system sandbox, so only enable +plugins whose source and publisher you trust. + +Root-level legacy Python plugins load only with `allow_legacy=True`. diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index beb0bb8..c8415b6 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -24,18 +24,23 @@ backup-management, and source-attributed knowledge application. dynamically loaded knowledge adapters. - Local inventory for Xbox content roots, USB drives, archive folders, Games on Demand, XBLA, DLC, title updates, and extracted Xbox/Xbox 360 games. -- Public STFS and XBE header inspection for TitleID, MediaID, content type, disc, - and display metadata. +- Public STFS, XBE, and XEX2 header inspection for TitleID, MediaID, versions, + content type, disc, and display metadata. +- Mounted-storage discovery, read-only Aurora database import, exact MediaID + Title Update comparison, collection health scoring, and repair previews. +- Preservation hash matching against imported DAT metadata, offline HTML + reports, manifests, provenance exports, and separate local overrides. - Safe bare-package and ZIP installation with path validation, `.partial` staging, SHA-256 verification, and atomic final placement. - Verified archive export with portable JSON manifests and per-file checksums. -- Aurora-oriented FTP package upload with one connection per operation, - temporary remote names, and no stored passwords. +- Persistent resumable FTP upload/download jobs, partial-file recovery, + bandwidth limits, verified final sizes, read-only console snapshots, and no + stored passwords. - Explicit external converter integration for user-owned ISO images. - Local-by-default REST API with token-required remote binding, restricted browser origins, validated settings, and current version reporting. -- Cross-platform CI, Windows packaging checks, tagged release archives, and - SHA-256 release checksums. +- Cross-platform CI, Windows packaging checks, tagged release archives, + SHA-256 checksums, CycloneDX SBOMs, and build-provenance attestations. - Linux x86_64 packaging, XDG storage, application-menu integration, source launch scripts, and release artifacts. - Repository contribution, security, architecture, API, and release @@ -70,8 +75,7 @@ backup-management, and source-attributed knowledge application. ## Future Work -- Parse additional XEX fields and link scanned file identifiers directly to - normalized knowledge entities. - Add field-specific source-priority controls and conflict resolution actions. -- Add optional scheduled knowledge refreshes and offline HTML reports. -- Add resumable FTP queues and optional Aurora database inventory. +- Add optional scheduled knowledge refreshes. +- Validate console resume behavior against a broader matrix of dashboard FTP + servers and add opt-in remote hash verification where servers expose it. diff --git a/README.md b/README.md index 743948c..1965e6c 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,20 @@ service exposes. UnityScraper does not silently substitute HTTPS URLs. `Content/0000000000000000` trees. - Exports selected backups with per-file SHA-256 values and a preservation manifest. -- Uploads packages to a configured Aurora-style FTP server. +- Queues resumable uploads and downloads to a configured Aurora-style FTP server. +- Captures read-only console inventories and compares PC and console content. - Runs a user-selected external ISO converter without bundling converter code. +### Collection Intelligence and Preservation + +- Discovers mounted console, USB, and archive storage. +- Parses XEX2 identity fields and imports Aurora databases read-only. +- Compares installed content with catalogued updates using exact MediaIDs. +- Scores collection health and creates non-destructive repair-plan previews. +- Matches local hashes against user-imported Redump and No-Intro DAT metadata. +- Exports preservation manifests, offline HTML reports, and fact provenance. +- Keeps local metadata overrides separate from source-attributed knowledge. + ## Install ### Windows Release @@ -120,6 +131,7 @@ Linux source setup: | Add Games | Import or enter TitleIDs | | Downloads | Review and manage download activity | | Backup Manager | Scan, install, verify, export, convert, and transfer owned content | +| Collections | Identify storage, compare Title Updates, verify preservation data, and preview repairs | | Knowledge | Search sources, facts, citations, imports, and conflicts | | Archive Health | Find missing or inconsistent downloaded files | | Settings | Configure storage and scraper behavior | @@ -212,6 +224,24 @@ python main.py --ftp-upload game.live --ftp-host 192.168.1.50 See [BACKUP_MANAGER.md](BACKUP_MANAGER.md) for layouts, conflict behavior, manifests, FTP considerations, and external converter arguments. +### Collections and Console Sync + +```powershell +# Analyze a collection and create offline reports +python main.py --analyze-collection D:\Xbox360 ` + --collection-manifest collection.json ` + --collection-html collection.html + +# Read an Aurora database without modifying it +python main.py --aurora-db content.db --collection-manifest aurora.json + +# Match a local file against imported preservation DAT hashes +python main.py --match-file game.iso + +# Capture a read-only console inventory +python main.py --ftp-host 192.168.1.50 --ftp-snapshot /Hdd1 +``` + ## Optional REST API Start the localhost-only API: @@ -271,6 +301,9 @@ SHA-256 file. - [Linux support](LINUX.md) - [Knowledge sources and licensing](KNOWLEDGE_SOURCES.md) - [Backup manager](BACKUP_MANAGER.md) +- [Collection intelligence](COLLECTION_INTELLIGENCE.md) +- [Console sync](CONSOLE_SYNC.md) +- [Plugin API v1](PLUGIN_API.md) - [REST API](API.md) - [Project status](PROJECT_STATUS.md) - [Changelog](CHANGELOG.md) diff --git a/UnityScraper.spec b/UnityScraper.spec index 2863a23..b4ce347 100644 --- a/UnityScraper.spec +++ b/UnityScraper.spec @@ -14,11 +14,17 @@ a = Analysis( 'backup_gui', 'backup_manager', 'backup_service', + 'collection_gui', + 'collection_intelligence', + 'console_sync', 'consolemods_adapters', + 'database_migrations', 'dat_adapters', 'knowledge_gui', 'knowledge_service', 'knowledge_sync', + 'plugins', + 'updater', 'wiki_adapters', ], hookspath=[], diff --git a/VERSION b/VERSION index d21ee86..9a7c866 100644 --- a/VERSION +++ b/VERSION @@ -1,6 +1,6 @@ { - "version": "0.10.0b1", - "name": "Xbox 360 Knowledge and Backup Manager", + "version": "1.0.0b1", + "name": "Unified Xbox 360 Collection and Preservation Manager", "changes": [ "Unified Xbox 360 knowledge browser", "ConsoleMods, XenonLibrary, and Free60 wiki ingestion", @@ -9,7 +9,14 @@ "Local Xbox content and Games folder inventory", "Safe STFS package and ZIP installation", "Verified exports with preservation manifests", - "FTP console transfer and external ISO converter support" + "Persistent resumable FTP console synchronization", + "XEX identity parsing and exact MediaID title-update matching", + "Mounted-storage discovery and read-only Aurora database import", + "Collection health scoring and repair-plan previews", + "Redump and No-Intro local-file hash matching", + "Offline HTML reports, manifests, provenance, and metadata overrides", + "Versioned migrations, database backups, queue recovery, and plugin API v1", + "Platform-aware verified updates, SBOMs, and build attestations" ], "download_url": "https://github.com/TrapEmAll/UnityScraper/releases", "release_date": "2026-07-23" diff --git a/app_version.py b/app_version.py index 2bc5d2e..35497f9 100644 --- a/app_version.py +++ b/app_version.py @@ -1,4 +1,4 @@ """Single source of truth for UnityScraper version information.""" -APP_VERSION = "0.10.0b1" -DISPLAY_VERSION = "0.10.0-beta.1" +APP_VERSION = "1.0.0b1" +DISPLAY_VERSION = "1.0.0-beta.1" diff --git a/backup_gui.py b/backup_gui.py index 03cac48..1222b4b 100644 --- a/backup_gui.py +++ b/backup_gui.py @@ -5,6 +5,7 @@ import threading import tkinter as tk from pathlib import Path +from pathlib import PurePosixPath from tkinter import filedialog, messagebox, ttk from typing import Callable, Optional @@ -13,8 +14,10 @@ ExternalConverter, FtpBackupClient, FtpTarget, + inspect_stfs, ) from backup_service import BackupService +from console_sync import ConsoleSyncService class BackupPage: @@ -30,6 +33,7 @@ def __init__( self.root = root self.parent = parent self.service = service + self.console_sync = ConsoleSyncService(service.repository.db_path) self.items: dict[str, BackupItem] = {} self.busy = False @@ -117,6 +121,12 @@ def _build_inventory(self) -> None: ttk.Button( controls, text="Verify Selected", command=self.verify_selected ).pack(side=tk.LEFT, padx=(8, 0)) + ttk.Button(controls, text="Verify All", command=self.verify_all).pack( + side=tk.LEFT, padx=(8, 0) + ) + ttk.Button(controls, text="Export All", command=self.export_all).pack( + side=tk.LEFT, padx=(8, 0) + ) def _build_transfer(self) -> None: tab = self.transfer_tab @@ -151,10 +161,34 @@ def _build_transfer(self) -> None: ) ttk.Button( controls, - text="Upload Package", - command=self.upload_ftp, + text="Queue & Upload", + command=self.queue_upload, style="Accent.TButton", ).pack(side=tk.LEFT, padx=(8, 0)) + ttk.Button(controls, text="Pause", command=self.pause_transfer).pack( + side=tk.LEFT, padx=(8, 0) + ) + ttk.Button(controls, text="Resume", command=self.resume_transfer).pack( + side=tk.LEFT, padx=(8, 0) + ) + ttk.Button(controls, text="Snapshot Console", command=self.snapshot_console).pack( + side=tk.LEFT, padx=(8, 0) + ) + ttk.Button(controls, text="Compare Latest", command=self.compare_console).pack( + side=tk.LEFT, padx=(8, 0) + ) + limit_row = ttk.Frame(tab) + limit_row.grid(row=8, column=0, columnspan=2, sticky="ew", pady=(12, 0)) + ttk.Label(limit_row, text="Bandwidth limit (KiB/s)").pack(side=tk.LEFT) + self.ftp_limit_var = tk.StringVar(value="0") + ttk.Spinbox( + limit_row, from_=0, to=102400, textvariable=self.ftp_limit_var, width=10 + ).pack(side=tk.LEFT, padx=(8, 0)) + self.queue_var = tk.StringVar(value="Persistent queue: empty") + ttk.Label(tab, textvariable=self.queue_var, style="Subheader.TLabel").grid( + row=9, column=0, columnspan=2, sticky=tk.W, pady=(8, 0) + ) + self._refresh_queue_status() def _build_converter(self) -> None: tab = self.converter_tab @@ -341,6 +375,41 @@ def _verify_done(self, issues) -> None: self.status_var.set("Verification completed with no structural issues.") messagebox.showinfo("Verification", "No structural issues found.", parent=self.root) + def verify_all(self) -> None: + if not self.items: + messagebox.showinfo("Verification", "Scan a target first.", parent=self.root) + return + self._run( + "Checking all inventoried backups...", + lambda: self.service.verify_many(list(self.items.values())), + self._verify_batch_done, + ) + + def _verify_batch_done(self, findings: list[dict]) -> None: + self.status_var.set( + f"Batch verification completed: {len(findings)} item(s) need attention." + ) + if findings: + messagebox.showwarning( + "Batch verification", + "\n".join( + f"{item['path']}: {', '.join(item['issues'])}" for item in findings[:15] + ), + parent=self.root, + ) + + def export_all(self) -> None: + if not self.items: + messagebox.showinfo("Export", "Scan a target first.", parent=self.root) + return + destination = filedialog.askdirectory(parent=self.root, title="Choose batch export folder") + if destination: + self._run( + "Exporting all inventoried backups...", + lambda: self.service.export_many(list(self.items.values()), destination), + lambda paths: self._operation_done(f"Exported {len(paths)} item(s)."), + ) + def _ftp_target(self) -> FtpTarget: host = self.ftp_host_var.get().strip() if not host: @@ -372,6 +441,99 @@ def upload_ftp(self) -> None: ), ) + def queue_upload(self) -> None: + source = filedialog.askopenfilename(parent=self.root, title="Choose STFS package") + if not source: + return + try: + package = inspect_stfs(source) + remote = str( + PurePosixPath(self.ftp_content_var.get().strip()) + / package.title_id + / package.content_directory + / Path(source).name + ) + job_id = self.console_sync.enqueue( + "upload", + source, + remote, + bandwidth_limit=max(0, int(self.ftp_limit_var.get() or "0")) * 1024, + ) + except Exception as exc: + self._failed(exc) + return + self._refresh_queue_status() + self._run( + f"Transferring queued job {job_id}...", + lambda: self.console_sync.run_job(job_id, self._ftp_target()), + lambda result: self._sync_done(result), + ) + + def pause_transfer(self) -> None: + self.console_sync.pause() + self.status_var.set("Pause requested. Partial data will be kept for resume.") + + def resume_transfer(self) -> None: + paused = [job for job in self.console_sync.list_jobs() if job["status"] == "paused"] + if not paused: + messagebox.showinfo("Console transfer", "No paused job is available.", parent=self.root) + return + job_id = int(paused[0]["id"]) + self.console_sync.resume(job_id) + self._run( + f"Resuming job {job_id}...", + lambda: self.console_sync.run_job(job_id, self._ftp_target()), + lambda result: self._sync_done(result), + ) + + def snapshot_console(self) -> None: + self._run( + "Reading console inventory...", + lambda: self.console_sync.capture_inventory(self._ftp_target(), "/Hdd1"), + lambda snapshot_id: self._operation_done( + f"Read-only console snapshot {snapshot_id} saved." + ), + ) + + def compare_console(self) -> None: + snapshots = [ + item for item in self.console_sync.list_snapshots() + if item["status"] == "completed" + ] + if not snapshots: + messagebox.showinfo( + "Console comparison", "Capture a console snapshot first.", parent=self.root + ) + return + local = filedialog.askdirectory(parent=self.root, title="Choose matching PC folder") + if not local: + return + snapshot_id = int(snapshots[0]["id"]) + self._run( + f"Comparing with snapshot {snapshot_id}...", + lambda: self.console_sync.compare(local, snapshot_id), + lambda result: messagebox.showinfo( + "PC and console comparison", + f"Only on PC: {len(result.only_on_pc)}\n" + f"Only on console: {len(result.only_on_console)}\n" + f"Different size: {len(result.size_mismatches)}\n" + f"Matching: {len(result.matching)}", + parent=self.root, + ), + ) + + def _sync_done(self, result: dict) -> None: + self._refresh_queue_status() + self._operation_done( + f"Transfer {result['status']}: {result['transferred_bytes']} / " + f"{result['total_bytes']} bytes." + ) + + def _refresh_queue_status(self) -> None: + jobs = self.console_sync.list_jobs() + active = sum(job["status"] in {"queued", "transferring", "paused"} for job in jobs) + self.queue_var.set(f"Persistent queue: {active} active, {len(jobs)} total") + def run_converter(self) -> None: import shlex diff --git a/backup_manager.py b/backup_manager.py index 89ff7cd..b16df5f 100644 --- a/backup_manager.py +++ b/backup_manager.py @@ -76,6 +76,19 @@ class XbePackage: size: int +@dataclass(frozen=True) +class XexPackage: + path: Path + title_id: str + media_id: str + version: str + base_version: str + disc_number: int + disc_count: int + module_flags: int + size: int + + @dataclass class BackupItem: path: Path @@ -87,6 +100,8 @@ class BackupItem: content_type: str = "" status: str = "ready" notes: list[str] = field(default_factory=list) + disc_number: int = 0 + disc_count: int = 0 def to_dict(self) -> dict: result = asdict(self) @@ -195,6 +210,53 @@ def inspect_xbe(path: str | Path) -> XbePackage: return XbePackage(package_path, title_id, title_name, package_path.stat().st_size) +def inspect_xex(path: str | Path) -> XexPackage: + """Read the public XEX2 execution-info header without decrypting content.""" + package_path = Path(path) + with package_path.open("rb") as handle: + header = handle.read(0x4000) + if len(header) < 0x18 or header[:4] != b"XEX2": + raise InvalidPackageError(f"{package_path.name} is not an XEX2 executable") + + module_flags = int.from_bytes(header[4:8], "big") + optional_count = int.from_bytes(header[0x14:0x18], "big") + if optional_count > 4096 or 0x18 + optional_count * 8 > len(header): + raise InvalidPackageError("XEX optional-header table is invalid") + + execution_offset = 0 + for index in range(optional_count): + entry = 0x18 + index * 8 + key = int.from_bytes(header[entry : entry + 4], "big") + value = int.from_bytes(header[entry + 4 : entry + 8], "big") + if key == 0x00040006: + execution_offset = value + break + if not execution_offset or execution_offset + 24 > len(header): + raise InvalidPackageError("XEX execution metadata is unavailable") + + info = header[execution_offset : execution_offset + 24] + + def format_version(value: int) -> str: + return ( + f"{(value >> 28) & 0xF}." + f"{(value >> 24) & 0xF}." + f"{(value >> 8) & 0xFFFF}." + f"{value & 0xFF}" + ) + + return XexPackage( + path=package_path, + title_id=info[12:16].hex().upper(), + media_id=info[0:4].hex().upper(), + version=format_version(int.from_bytes(info[4:8], "big")), + base_version=format_version(int.from_bytes(info[8:12], "big")), + disc_number=info[18], + disc_count=info[19], + module_flags=module_flags, + size=package_path.stat().st_size, + ) + + def sha256_file(path: str | Path) -> str: digest = hashlib.sha256() with Path(path).open("rb") as handle: @@ -255,6 +317,8 @@ def scan_local_target( notes = [] package_names: list[str] = [] media_ids: set[str] = set() + disc_numbers: set[int] = set() + disc_count = 0 malformed = 0 for type_name in sorted(types & set(CONTENT_TYPES[value][1] for value in CONTENT_TYPES)): for package_path in (title_dir / type_name).iterdir(): @@ -267,6 +331,9 @@ def scan_local_target( continue if package.media_id and package.media_id != "00000000": media_ids.add(package.media_id) + if package.disc_number: + disc_numbers.add(package.disc_number) + disc_count = max(disc_count, package.disc_count) candidate_name = package.title_name or package.display_name if candidate_name: package_names.append(candidate_name) @@ -275,6 +342,11 @@ def scan_local_target( warnings.append(f"{title_id} has support content but no base game") if malformed: notes.append(f"{malformed} package header(s) could not be identified") + if disc_count: + notes.append( + f"Discs found: {', '.join(str(value) for value in sorted(disc_numbers))} " + f"of {disc_count}" + ) name = title_lookup(title_id) if title_lookup else None items.append( BackupItem( @@ -291,6 +363,8 @@ def scan_local_target( size=directory_size(title_dir), status=status, notes=notes, + disc_number=min(disc_numbers) if len(disc_numbers) == 1 else 0, + disc_count=disc_count, ) ) @@ -301,22 +375,39 @@ def scan_local_target( title_name = game_dir.name media_id = "" format_name = "Extracted Xbox 360" - notes: list[str] = [] + extracted_notes: list[str] = [] + disc_number = 0 + disc_count = 0 xbe = game_dir / "default.xbe" xex = game_dir / "default.xex" if xbe.is_file(): try: - info = inspect_xbe(xbe) - title_id, title_name = info.title_id, info.title_name or title_name + xbe_info = inspect_xbe(xbe) + title_id = xbe_info.title_id + title_name = xbe_info.title_name or title_name format_name = "Extracted Original Xbox" except BackupError as exc: - notes.append(str(exc)) + extracted_notes.append(str(exc)) else: + if xex.is_file(): + try: + xex_info = inspect_xex(xex) + title_id = xex_info.title_id + media_id = xex_info.media_id if xex_info.media_id != "00000000" else "" + disc_number = xex_info.disc_number + disc_count = xex_info.disc_count + extracted_notes.append(f"XEX version {xex_info.version}") + if xex_info.disc_count: + extracted_notes.append( + f"Disc {xex_info.disc_number} of {xex_info.disc_count}" + ) + except BackupError as exc: + extracted_notes.append(str(exc)) folder_match = FOLDER_TITLE_ID_RE.search(game_dir.name) - if folder_match: + if not title_id and folder_match: title_id = folder_match.group(1).upper() if not xex.is_file(): - notes.append("default.xex or default.xbe is missing") + extracted_notes.append("default.xex or default.xbe is missing") if title_id and title_lookup: title_name = title_lookup(title_id) or title_name items.append( @@ -327,8 +418,10 @@ def scan_local_target( format=format_name, media_id=media_id, size=directory_size(game_dir), - status="ready" if not notes else "incomplete", - notes=notes, + status="ready" if not extracted_notes else "incomplete", + notes=extracted_notes, + disc_number=disc_number, + disc_count=disc_count, ) ) diff --git a/backup_service.py b/backup_service.py index 2578e1d..067ede4 100644 --- a/backup_service.py +++ b/backup_service.py @@ -11,6 +11,7 @@ from typing import Optional from app_paths import DATABASE_PATH, ensure_app_dirs +from database_migrations import ensure_application_schema from backup_manager import ( BackupItem, FtpBackupClient, @@ -120,6 +121,7 @@ def connect(self): def ensure_schema(self) -> None: with self.connect() as connection: ensure_backup_schema(connection) + ensure_application_schema(connection) def save_local_target(self, name: str, location: str | Path) -> int: now = datetime.now(timezone.utc).isoformat() @@ -360,6 +362,24 @@ def verify(self, item: BackupItem) -> list[str]: ) return issues + def verify_many(self, items: list[BackupItem]) -> list[dict]: + """Structurally verify an inventory batch.""" + findings = [] + for item in items: + issues = self.verify(item) + if issues: + findings.append({"path": str(item.path), "issues": issues}) + return findings + + def export_many( + self, + items: list[BackupItem], + destination: str | Path, + conflict: str = "skip", + ) -> list[Path]: + """Export an inventory batch using verified per-item exports.""" + return [self.export(item, destination, conflict) for item in items] + def upload_ftp( self, source: str | Path, target: FtpTarget ) -> TransferResult: diff --git a/build_linux.sh b/build_linux.sh index 02c0e28..c2f2709 100755 --- a/build_linux.sh +++ b/build_linux.sh @@ -26,6 +26,8 @@ install -m 0644 packaging/linux/io.github.trapemall.UnityScraper.metainfo.xml \ "$STAGE/io.github.trapemall.UnityScraper.metainfo.xml" install -m 0644 assets/UnityScraper.png "$STAGE/unityscraper.png" install -m 0644 README.md CHANGELOG.md LICENSE "$STAGE/" +install -m 0644 DOCS_INDEX.md BACKUP_MANAGER.md COLLECTION_INTELLIGENCE.md \ + CONSOLE_SYNC.md KNOWLEDGE_SOURCES.md LINUX.md PLUGIN_API.md "$STAGE/" tar -C dist -czf "$ARCHIVE" "UnityScraper-Linux-${ARCH}" sha256sum "$ARCHIVE" > "$ARCHIVE.sha256" diff --git a/collection_gui.py b/collection_gui.py new file mode 100644 index 0000000..53f5031 --- /dev/null +++ b/collection_gui.py @@ -0,0 +1,311 @@ +"""Desktop collection-intelligence workspace.""" + +from __future__ import annotations + +import threading +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox, simpledialog, ttk +from typing import Callable + +from collection_intelligence import ( + CollectionAnalysis, + CollectionIntelligenceService, + discover_storage_roots, +) + + +class CollectionPage: + def __init__( + self, + root: tk.Tk, + parent: ttk.Frame, + service: CollectionIntelligenceService, + page_header: Callable[[str, str], None], + ) -> None: + self.root = root + self.parent = parent + self.service = service + self.analysis: CollectionAnalysis | None = None + self.busy = False + page_header( + "Collection Intelligence", + "Identify games, exact title-update compatibility, preservation matches, and repairs.", + ) + self._build() + + def _build(self) -> None: + body = ttk.Frame(self.parent) + body.grid(row=1, column=0, sticky="nsew") + body.columnconfigure(0, weight=1) + body.rowconfigure(2, weight=1) + + controls = ttk.Frame(body) + controls.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + controls.columnconfigure(1, weight=1) + ttk.Label(controls, text="Collection source").grid(row=0, column=0, padx=(0, 8)) + self.source_var = tk.StringVar() + ttk.Entry(controls, textvariable=self.source_var).grid( + row=0, column=1, sticky="ew" + ) + ttk.Button(controls, text="Discover", command=self.discover).grid( + row=0, column=2, padx=(8, 0) + ) + ttk.Button(controls, text="Browse", command=self.browse).grid( + row=0, column=3, padx=(8, 0) + ) + ttk.Button( + controls, text="Analyze", command=self.analyze, style="Accent.TButton" + ).grid(row=0, column=4, padx=(8, 0)) + ttk.Button(controls, text="Import Aurora DB", command=self.import_aurora).grid( + row=0, column=5, padx=(8, 0) + ) + + summary = ttk.Frame(body) + summary.grid(row=1, column=0, sticky="ew", pady=(0, 10)) + self.score_var = tk.StringVar(value="Health: --") + self.count_var = tk.StringVar(value="Games: --") + self.issue_var = tk.StringVar(value="Issues: --") + for variable in (self.score_var, self.count_var, self.issue_var): + ttk.Label(summary, textvariable=variable, style="Metric.TLabel", padding=10).pack( + side=tk.LEFT, padx=(0, 8) + ) + + columns = ("titleid", "mediaid", "format", "tu", "status") + self.tree = ttk.Treeview(body, columns=columns, show="tree headings") + self.tree.heading("#0", text="Game") + for column, label in zip( + columns, ("TitleID", "MediaID", "Format", "Title update", "Status") + ): + self.tree.heading(column, text=label) + self.tree.column("#0", width=270) + self.tree.column("titleid", width=90, anchor=tk.CENTER) + self.tree.column("mediaid", width=90, anchor=tk.CENTER) + self.tree.column("format", width=180) + self.tree.column("tu", width=130) + self.tree.column("status", width=100, anchor=tk.CENTER) + self.tree.grid(row=2, column=0, sticky="nsew") + + actions = ttk.Frame(body) + actions.grid(row=3, column=0, sticky="ew", pady=(10, 0)) + action_specs = ( + ("Verify Selected", self.verify_selected), + ("Repair Plan", self.repair_plan), + ("Edit Metadata", self.edit_metadata), + ("Export Manifest", self.export_manifest), + ("Export HTML", self.export_html), + ("Export Aurora", self.export_aurora), + ("Export Provenance", self.export_provenance), + ) + for column_index in range(4): + actions.columnconfigure(column_index, weight=1) + for index, (label, command) in enumerate(action_specs): + ttk.Button(actions, text=label, command=command).grid( + row=index // 4, + column=index % 4, + sticky="ew", + padx=(0 if index % 4 == 0 else 8, 0), + pady=(0 if index < 4 else 8, 0), + ) + self.status_var = tk.StringVar(value="Choose a folder or discover mounted storage.") + ttk.Label(body, textvariable=self.status_var, style="Subheader.TLabel").grid( + row=4, column=0, sticky="ew", pady=(8, 0) + ) + + def discover(self) -> None: + roots = discover_storage_roots() + if roots: + self.source_var.set(str(roots[0])) + self.status_var.set( + f"Found {len(roots)} mounted location(s); showing the strongest match." + ) + else: + self.status_var.set("No mounted collection root was detected.") + + def browse(self) -> None: + selected = filedialog.askdirectory(parent=self.root, title="Choose collection root") + if selected: + self.source_var.set(selected) + + def analyze(self) -> None: + source = self.source_var.get().strip() + if not source: + messagebox.showwarning("Collection source", "Choose a folder first.", parent=self.root) + return + self._run("Analyzing collection...", lambda: self.service.analyze(source)) + + def import_aurora(self) -> None: + selected = filedialog.askopenfilename( + parent=self.root, + title="Choose an Aurora database", + filetypes=(("SQLite databases", "*.db *.sqlite *.sqlite3"), ("All files", "*.*")), + ) + if selected: + self.source_var.set(selected) + self._run("Reading Aurora database...", lambda: self.service.analyze_aurora(selected)) + + def _run(self, message: str, operation) -> None: + if self.busy: + return + self.busy = True + self.status_var.set(message) + + def worker() -> None: + try: + result = operation() + except Exception as exc: + error = exc + + def report_error() -> None: + self._failed(error) + + self.root.after(0, report_error) + else: + self.root.after(0, lambda: self._finished(result)) + + threading.Thread(target=worker, daemon=True).start() + + def _failed(self, error: Exception) -> None: + self.busy = False + self.status_var.set("Analysis failed.") + messagebox.showerror("Collection analysis failed", str(error), parent=self.root) + + def _finished(self, analysis: CollectionAnalysis) -> None: + self.busy = False + self.analysis = analysis + self.tree.delete(*self.tree.get_children()) + for index, item in enumerate(analysis.result.items): + match = analysis.compatibility[str(item.path)] + self.tree.insert( + "", + tk.END, + iid=str(index), + text=item.name, + values=( + item.title_id, + item.media_id, + item.format, + match.status, + item.status, + ), + ) + self.score_var.set(f"Health: {analysis.health_score}") + self.count_var.set(f"Games: {len(analysis.result.items)}") + self.issue_var.set(f"Issues: {len(analysis.issues)}") + self.status_var.set( + f"Snapshot {analysis.snapshot_id} saved. No repair action has been executed." + ) + + def _require_analysis(self) -> CollectionAnalysis | None: + if self.analysis is None: + messagebox.showinfo("Collection", "Analyze a collection first.", parent=self.root) + return self.analysis + + def verify_selected(self) -> None: + analysis = self._require_analysis() + selected = self.tree.selection() + if not analysis or not selected: + return + item = analysis.result.items[int(selected[0])] + if not item.path.is_file(): + messagebox.showinfo( + "Verification", + "Select a package file to hash-match. Folder verification is represented in the scan.", + parent=self.root, + ) + return + self._run_hash(item.path) + + def _run_hash(self, path: Path) -> None: + self.status_var.set(f"Hashing {path.name}...") + + def worker() -> None: + try: + matches = self.service.hash_and_match(path) + except Exception as exc: + error = exc + + def report_error() -> None: + self._failed(error) + + self.root.after(0, report_error) + else: + self.root.after( + 0, + lambda: self.status_var.set( + f"Verification complete: {len(matches)} Redump/No-Intro match(es)." + ), + ) + + threading.Thread(target=worker, daemon=True).start() + + def repair_plan(self) -> None: + analysis = self._require_analysis() + if analysis: + plan_id = self.service.create_repair_plan(analysis) + messagebox.showinfo( + "Repair plan", + f"Preview plan {plan_id} contains {len(analysis.issues)} proposed action(s).\n\n" + "Nothing was changed.", + parent=self.root, + ) + + def edit_metadata(self) -> None: + analysis = self._require_analysis() + selected = self.tree.selection() + if not analysis or not selected: + return + item = analysis.result.items[int(selected[0])] + if not item.title_id: + messagebox.showinfo( + "Metadata override", "This item needs a TitleID first.", parent=self.root + ) + return + value = simpledialog.askstring( + "Local game name", + f"Preferred local name for {item.title_id}:", + initialvalue=item.name, + parent=self.root, + ) + if value and value.strip(): + self.service.set_override(item.title_id, "name", value.strip()) + item.name = value.strip() + self.tree.item(selected[0], text=item.name) + self.status_var.set( + "Local override saved separately; imported source facts were not changed." + ) + + def export_manifest(self) -> None: + analysis = self._require_analysis() + if analysis: + self.status_var.set(f"Manifest written to {self.service.export_manifest(analysis)}") + + def export_html(self) -> None: + analysis = self._require_analysis() + if analysis: + self.status_var.set(f"HTML report written to {self.service.export_html(analysis)}") + + def export_provenance(self) -> None: + selected = filedialog.asksaveasfilename( + parent=self.root, + title="Export provenance", + defaultextension=".json", + filetypes=(("JSON", "*.json"),), + ) + if selected: + self.status_var.set( + f"Provenance written to {self.service.export_provenance(selected)}" + ) + + def export_aurora(self) -> None: + analysis = self._require_analysis() + if not analysis: + return + selected = filedialog.askdirectory(parent=self.root, title="Choose Aurora export root") + if selected: + try: + output = self.service.export_aurora_layout(analysis, selected) + except Exception as exc: + self._failed(exc) + else: + self.status_var.set(f"Aurora layout exported to {output}") diff --git a/collection_intelligence.py b/collection_intelligence.py new file mode 100644 index 0000000..897e4d7 --- /dev/null +++ b/collection_intelligence.py @@ -0,0 +1,651 @@ +"""Collection discovery, compatibility, preservation, and reporting services.""" + +from __future__ import annotations + +import hashlib +import html +import json +import os +import re +import sqlite3 +import shutil +import sys +import zlib +from contextlib import closing, contextmanager +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from app_paths import DATABASE_PATH, EXPORTS_DIR, ensure_app_dirs +from backup_manager import BackupItem, ScanResult, scan_local_target +from knowledge_base import KnowledgeRepository + + +HEX8_RE = re.compile(r"^[0-9A-Fa-f]{8}$") + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass(frozen=True) +class UpdateCompatibility: + title_id: str + media_id: str + status: str + newest_version: str = "" + update_count: int = 0 + reason: str = "" + + +@dataclass(frozen=True) +class CollectionIssue: + severity: str + code: str + target: str + message: str + suggested_action: str + destructive: bool = False + + +@dataclass +class CollectionAnalysis: + result: ScanResult + compatibility: dict[str, UpdateCompatibility] = field(default_factory=dict) + issues: list[CollectionIssue] = field(default_factory=list) + health_score: int = 100 + snapshot_id: int | None = None + + def to_dict(self) -> dict: + return { + "schema": 1, + "snapshot_id": self.snapshot_id, + "health_score": self.health_score, + "scan": self.result.to_dict(), + "compatibility": {key: asdict(value) for key, value in self.compatibility.items()}, + "issues": [asdict(issue) for issue in self.issues], + } + + +def discover_storage_roots() -> list[Path]: + """Return mounted roots that are plausible console, USB, or archive targets.""" + candidates: list[Path] = [] + if os.name == "nt": + import ctypes + + mask = ctypes.windll.kernel32.GetLogicalDrives() + for index in range(26): + if mask & (1 << index): + candidates.append(Path(f"{chr(65 + index)}:/")) + elif sys.platform == "darwin": + candidates.extend(_children(Path("/Volumes"))) + else: + user = os.environ.get("USER", "") + candidates.extend(_children(Path("/media") / user)) + candidates.extend(_children(Path("/run/media") / user)) + candidates.extend(_children(Path("/mnt"))) + + scored: list[tuple[int, Path]] = [] + for path in dict.fromkeys(candidates): + if not path.is_dir(): + continue + score = sum( + int((path / relative).exists()) + for relative in ("Content", "Games", "Xbox360", "Aurora", "Data") + ) + scored.append((score, path)) + return [path for _, path in sorted(scored, key=lambda value: (-value[0], str(value[1])))] + + +def _children(path: Path) -> list[Path]: + try: + return [child for child in path.iterdir() if child.is_dir()] + except OSError: + return [] + + +def import_aurora_database(path: str | Path) -> ScanResult: + """Read a user-selected Aurora SQLite database in immutable, read-only mode.""" + source = Path(path).expanduser().resolve() + if not source.is_file(): + raise FileNotFoundError(source) + uri = f"{source.as_uri()}?mode=ro&immutable=1" + items: list[BackupItem] = [] + with closing(sqlite3.connect(uri, uri=True)) as connection: + connection.row_factory = sqlite3.Row + tables = [ + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ) + ] + for table in tables: + quoted = '"' + table.replace('"', '""') + '"' + columns = { + row[1].casefold(): row[1] + for row in connection.execute(f"PRAGMA table_info({quoted})") + } + title_col = _column(columns, "titleid", "title_id", "title id") + name_col = _column(columns, "name", "title", "displayname", "display_name") + if not title_col or not name_col: + continue + media_col = _column(columns, "mediaid", "media_id") + path_col = _column(columns, "path", "file_path", "contentpath", "content_path") + selected = [title_col, name_col] + selected.extend(value for value in (media_col, path_col) if value) + column_sql = ", ".join('"' + value.replace('"', '""') + '"' for value in selected) + for row in connection.execute(f"SELECT {column_sql} FROM {quoted}"): + title_id = str(row[title_col] or "").strip().upper().replace("0X", "") + if title_id and not HEX8_RE.fullmatch(title_id): + continue + items.append( + BackupItem( + path=Path(str(row[path_col] or "")) if path_col else source, + title_id=title_id, + name=str(row[name_col] or title_id or "Unknown"), + format="Aurora database", + media_id=str(row[media_col] or "").strip().upper() if media_col else "", + size=0, + ) + ) + if items: + break + warnings = [] if items else ["No compatible Aurora title table was found"] + return ScanResult(source, items, warnings, utc_now()) + + +def _column(columns: dict[str, str], *aliases: str) -> str | None: + return next((columns[alias] for alias in aliases if alias in columns), None) + + +class CollectionIntelligenceService: + def __init__(self, db_path: str | Path = DATABASE_PATH) -> None: + self.db_path = Path(db_path) + if self.db_path == DATABASE_PATH: + ensure_app_dirs() + else: + self.db_path.parent.mkdir(parents=True, exist_ok=True) + # Use the application's complete initializer so CLI-only collection + # workflows also have the legacy library and normalized knowledge tables. + from database import DatabaseManager + + DatabaseManager(str(self.db_path)) + + @contextmanager + def _connect(self): + connection = sqlite3.connect(self.db_path) + connection.row_factory = sqlite3.Row + try: + yield connection + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() + + def analyze(self, root: str | Path, title_lookup=None) -> CollectionAnalysis: + return self.analyze_result(scan_local_target(root, title_lookup), "local") + + def analyze_aurora(self, path: str | Path) -> CollectionAnalysis: + return self.analyze_result(import_aurora_database(path), "aurora") + + def analyze_result(self, result: ScanResult, source_kind: str) -> CollectionAnalysis: + compatibility: dict[str, UpdateCompatibility] = {} + issues: list[CollectionIssue] = [] + with self._connect() as connection: + for item in result.items: + compatibility[str(item.path)] = self._compatibility(connection, item) + if item.status != "ready": + issues.append( + CollectionIssue( + "warning", + "incomplete", + str(item.path), + f"{item.name} is incomplete", + "Review the item and restore its missing base content.", + ) + ) + if not item.title_id: + issues.append( + CollectionIssue( + "warning", + "unknown-titleid", + str(item.path), + f"{item.name} has no readable TitleID", + "Inspect its executable or add a metadata override.", + ) + ) + if item.title_id and not item.media_id: + issues.append( + CollectionIssue( + "info", + "unknown-mediaid", + str(item.path), + f"{item.name} has no MediaID", + "Scan an executable or package before choosing a title update.", + ) + ) + for note in item.notes: + if "missing" in note.casefold() or "could not" in note.casefold(): + issues.append( + CollectionIssue( + "warning", "scan-note", str(item.path), note, "Verify this item." + ) + ) + + groups: dict[tuple[str, str], list[BackupItem]] = {} + for item in result.items: + if item.title_id: + groups.setdefault((item.title_id, item.media_id), []).append(item) + for key, matches in groups.items(): + if len(matches) > 1: + issues.append( + CollectionIssue( + "info", + "duplicate-release", + key[0], + f"{len(matches)} copies share TitleID {key[0]} and " + f"MediaID {key[1] or 'unknown'}", + "Hash the copies and keep intentional regional or revision variants.", + ) + ) + expected = max((item.disc_count for item in matches), default=0) + present = {item.disc_number for item in matches if item.disc_number} + for item in matches: + for note in item.notes: + disc_match = re.search(r"Discs found: ([0-9, ]+) of ([0-9]+)", note) + if disc_match: + present.update( + int(value.strip()) + for value in disc_match.group(1).split(",") + if value.strip() + ) + expected = max(expected, int(disc_match.group(2))) + missing = sorted(set(range(1, expected + 1)) - present) if expected else [] + if missing: + issues.append( + CollectionIssue( + "warning", + "missing-disc", + key[0], + f"Missing disc(s) {', '.join(map(str, missing))} of {expected}", + "Locate or restore the missing disc backup.", + ) + ) + score = max( + 0, + 100 + - sum( + {"error": 15, "warning": 7, "info": 2}.get(issue.severity, 1) + for issue in issues + ), + ) + analysis = CollectionAnalysis(result, compatibility, issues, score) + analysis.snapshot_id = self._save_snapshot(connection, analysis, source_kind) + connection.commit() + return analysis + + @staticmethod + def _compatibility( + connection: sqlite3.Connection, item: BackupItem + ) -> UpdateCompatibility: + if not item.title_id: + return UpdateCompatibility("", item.media_id, "unknown", reason="TitleID is unknown") + rows = connection.execute( + "SELECT media_id, version FROM title_updates WHERE titleid=? ORDER BY version DESC", + (item.title_id,), + ).fetchall() + if not rows: + return UpdateCompatibility( + item.title_id, item.media_id, "none", reason="No title updates are catalogued" + ) + exact = [ + row + for row in rows + if item.media_id and (row["media_id"] or "").upper() == item.media_id + ] + if exact: + return UpdateCompatibility( + item.title_id, item.media_id, "compatible", exact[0]["version"] or "", len(exact) + ) + if not item.media_id: + return UpdateCompatibility( + item.title_id, + "", + "media-id-required", + rows[0]["version"] or "", + len(rows), + "Updates exist, but exact compatibility requires a MediaID", + ) + return UpdateCompatibility( + item.title_id, + item.media_id, + "incompatible", + rows[0]["version"] or "", + len(rows), + "No catalogued update matches this MediaID", + ) + + def _save_snapshot( + self, connection: sqlite3.Connection, analysis: CollectionAnalysis, source_kind: str + ) -> int: + result = analysis.result + cursor = connection.execute( + """ + INSERT INTO collection_snapshots + (source_kind, source_location, label, started_at, completed_at, + item_count, total_size, health_score, status, warnings_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'completed', ?) + """, + ( + source_kind, + str(result.root), + result.root.name, + result.scanned_at, + utc_now(), + len(result.items), + result.total_size, + analysis.health_score, + json.dumps(result.warnings), + ), + ) + if cursor.lastrowid is None: + raise sqlite3.DatabaseError("Collection snapshot did not return an ID") + snapshot_id = int(cursor.lastrowid) + for item in result.items: + match = analysis.compatibility[str(item.path)] + connection.execute( + """ + INSERT INTO collection_items + (snapshot_id, titleid, media_id, name, format, content_type, + path, size, disc_number, disc_count, status, compatibility, notes_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + snapshot_id, + item.title_id, + item.media_id, + item.name, + item.format, + item.content_type, + str(item.path), + item.size, + item.disc_number, + item.disc_count, + item.status, + match.status, + json.dumps(item.notes), + ), + ) + return snapshot_id + + def create_repair_plan(self, analysis: CollectionAnalysis) -> int: + """Persist a preview only; no filesystem operation is executed.""" + with self._connect() as connection: + cursor = connection.execute( + """ + INSERT INTO repair_plans(snapshot_id, created_at, status, summary_json) + VALUES (?, ?, 'preview', ?) + """, + ( + analysis.snapshot_id, + utc_now(), + json.dumps({"health_score": analysis.health_score, "issues": len(analysis.issues)}), + ), + ) + plan_id = int(cursor.lastrowid) + for issue in analysis.issues: + connection.execute( + """ + INSERT INTO repair_actions + (plan_id, action_type, target, reason, destructive, details_json) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + plan_id, + issue.code, + issue.target, + issue.message, + int(issue.destructive), + json.dumps(asdict(issue)), + ), + ) + connection.commit() + return plan_id + + def set_override( + self, + identifier_value: str, + property_name: str, + value: str, + *, + identifier_type: str = "titleid", + entity_type: str = "game", + notes: str = "", + ) -> None: + with self._connect() as connection: + connection.execute( + """ + INSERT INTO metadata_overrides + (entity_type, identifier_type, identifier_value, property, + value, notes, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(entity_type, identifier_type, identifier_value, property) + DO UPDATE SET value=excluded.value, notes=excluded.notes, + updated_at=excluded.updated_at + """, + ( + entity_type, + identifier_type, + identifier_value.upper(), + property_name, + value, + notes, + utc_now(), + ), + ) + connection.commit() + + def list_overrides(self, identifier_value: str | None = None) -> list[dict]: + with self._connect() as connection: + if identifier_value: + rows = connection.execute( + """ + SELECT * FROM metadata_overrides + WHERE identifier_value=? ORDER BY property + """, + (identifier_value.upper(),), + ).fetchall() + else: + rows = connection.execute( + "SELECT * FROM metadata_overrides ORDER BY updated_at DESC" + ).fetchall() + return [dict(row) for row in rows] + + def hash_and_match(self, path: str | Path) -> list[dict]: + source = Path(path).resolve() + stat = source.stat() + crc = 0 + hashes = (hashlib.md5(), hashlib.sha1(), hashlib.sha256()) + with source.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + crc = zlib.crc32(chunk, crc) + for digest in hashes: + digest.update(chunk) + values = { + "crc32": f"{crc & 0xFFFFFFFF:08X}", + "md5": hashes[0].hexdigest().upper(), + "sha1": hashes[1].hexdigest().upper(), + "sha256": hashes[2].hexdigest().upper(), + } + with self._connect() as connection: + connection.execute( + """ + INSERT INTO local_file_hashes + (path, size, modified_ns, crc32, md5, sha1, sha256, calculated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(path, size, modified_ns) DO UPDATE SET + crc32=excluded.crc32, md5=excluded.md5, sha1=excluded.sha1, + sha256=excluded.sha256, calculated_at=excluded.calculated_at + """, + ( + str(source), + stat.st_size, + stat.st_mtime_ns, + values["crc32"], + values["md5"], + values["sha1"], + values["sha256"], + utc_now(), + ), + ) + file_hash_id = int( + connection.execute( + "SELECT id FROM local_file_hashes WHERE path=? AND size=? AND modified_ns=?", + (str(source), stat.st_size, stat.st_mtime_ns), + ).fetchone()[0] + ) + matches: list[dict] = [] + for kind, value in values.items(): + rows = connection.execute( + """ + SELECT e.id, e.entity_type, e.canonical_name, i.identifier_type + FROM entity_identifiers i JOIN knowledge_entities e ON e.id=i.entity_id + WHERE i.identifier_type=? AND UPPER(i.normalized_value)=? + """, + (kind, value), + ).fetchall() + for row in rows: + match = dict(row) + match["matched_by"] = kind + matches.append(match) + connection.execute( + """ + INSERT OR IGNORE INTO preservation_matches + (file_hash_id, entity_id, identifier_type, + identifier_value, matched_at) + VALUES (?, ?, ?, ?, ?) + """, + (file_hash_id, row["id"], kind, value, utc_now()), + ) + connection.commit() + return matches + + def export_manifest( + self, analysis: CollectionAnalysis, destination: str | Path | None = None + ) -> Path: + ensure_app_dirs() + target = Path(destination) if destination else EXPORTS_DIR / "collection-manifest.json" + target.parent.mkdir(parents=True, exist_ok=True) + payload = analysis.to_dict() + payload.update({"generated_at": utc_now(), "application": "UnityScraper"}) + payload["metadata_overrides"] = self.list_overrides() + target.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return target + + def export_aurora_layout( + self, + analysis: CollectionAnalysis, + destination: str | Path, + artwork_directory: str | Path | None = None, + ) -> Path: + """Export an explicit Aurora-friendly Games and Assets layout.""" + target = Path(destination).expanduser().resolve() + games = target / "Games" + assets = target / "Assets" + games.mkdir(parents=True, exist_ok=True) + if artwork_directory: + assets.mkdir(parents=True, exist_ok=True) + exported: list[dict] = [] + for item in analysis.result.items: + safe_name = re.sub(r'[<>:"/\\|?*]+', "_", item.name).strip(" .") or "Unknown" + folder_name = f"{safe_name} [{item.title_id}]" if item.title_id else safe_name + output = games / folder_name + if output.exists(): + raise FileExistsError(f"Aurora export destination exists: {output}") + if item.path.is_dir() and target.is_relative_to(item.path.resolve()): + raise ValueError("Aurora export destination cannot be inside its source") + if item.path.is_dir(): + shutil.copytree(item.path, output) + elif item.path.is_file(): + output.mkdir(parents=True) + shutil.copy2(item.path, output / item.path.name) + else: + continue + artwork_files = [] + if artwork_directory and item.title_id: + source_art = Path(artwork_directory) + for extension in (".png", ".jpg", ".jpeg"): + candidate = source_art / f"{item.title_id}{extension}" + if candidate.is_file(): + artwork_target = assets / item.title_id + artwork_target.mkdir(parents=True, exist_ok=True) + copied = artwork_target / candidate.name + shutil.copy2(candidate, copied) + artwork_files.append(str(copied.relative_to(target))) + exported.append( + { + "titleid": item.title_id, + "media_id": item.media_id, + "source": str(item.path), + "destination": str(output.relative_to(target)), + "artwork": artwork_files, + } + ) + (target / "unityscraper-aurora-manifest.json").write_text( + json.dumps({"schema": 1, "generated_at": utc_now(), "items": exported}, indent=2), + encoding="utf-8", + ) + return target + + def export_html( + self, analysis: CollectionAnalysis, destination: str | Path | None = None + ) -> Path: + ensure_app_dirs() + target = Path(destination) if destination else EXPORTS_DIR / "collection-report.html" + rows = [] + for item in analysis.result.items: + compatibility = analysis.compatibility[str(item.path)] + rows.append( + "
Health score: {analysis.health_score}/100
+Generated {html.escape(utc_now())}; source {html.escape(str(analysis.result.root))}
+| Game | TitleID | MediaID | Format | +Title update | Bytes |
|---|