Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,27 @@ for CLI and optional REST automation. SQLite is the durable local store.
| `main.py` | XboxUnity, knowledge, backup, export, and API CLI |
| `GUI.py` | Legacy advanced scraper controls |
| `api.py` | Optional local automation API |
| `unityscraper.app.desktop.entrypoint` | Package entry point for installed desktop launches |
| `unityscraper.app.cli.entrypoint` | Package entry point for installed CLI launches |

The top-level entry points remain supported while the codebase moves into the
`unityscraper` package. See [Modularization plan](MODULARIZATION_PLAN.md).

## Package Direction

New code should prefer the package layout:

```text
unityscraper/
app/ desktop, CLI, and API adapters
core/ shared paths, jobs, database contracts, and infrastructure
domains/ feature-owned library, knowledge, backup, profile, package,
collection, console sync, tool, and plugin modules
```

Domain modules should own business workflows and schema changes. Desktop pages,
REST routes, and CLI commands should call domain use cases instead of owning
filesystem, FTP, package, or database behavior directly.

## Layers

Expand Down
2 changes: 2 additions & 0 deletions DOCS_INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@

- [Architecture](ARCHITECTURE.md) - modules, layers, schemas, data flows, and
packaging
- [Modularization Plan](MODULARIZATION_PLAN.md) - package boundaries, domain
ownership, and the incremental AIO refactor path
- [Plugin API v1](PLUGIN_API.md) - manifests, opt-in loading, and compatibility
- [Contributing](CONTRIBUTING.md) - environment, tests, PR expectations, and
adapter rules
Expand Down
96 changes: 96 additions & 0 deletions MODULARIZATION_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Modularization Plan

UnityScraper is moving toward a modular monolith: one local desktop-first
application, with feature domains that own their models, services, schema,
commands, tests, and UI adapters.

The current top-level modules remain supported while code moves into the
`unityscraper` package incrementally.

## Target Shape

```text
unityscraper/
app/
desktop/
cli/
api/
core/
db/
jobs.py
paths.py
domains/
library/
knowledge/
backups/
profiles/
packages/
collections/
console_sync/
tools/
plugins/
```

## Boundaries

- `unityscraper.app` adapts desktop, CLI, and REST entry points.
- `unityscraper.core` holds shared infrastructure that has no Xbox-specific
product behavior.
- `unityscraper.domains` holds feature behavior and should avoid importing UI
modules.
- Top-level modules are compatibility shims or legacy implementations until
their behavior is moved behind domain packages.

## Domain Rules

Each domain should grow toward this internal layout:

```text
domain/
models.py
service.py
repository.py
commands.py
api.py
migrations.py
ui.py
```

- `models.py` contains dataclasses, enums, and validation types.
- `service.py` owns business workflows.
- `repository.py` owns SQLite access for that domain.
- `commands.py` exposes UI-neutral use cases for CLI, REST, and desktop jobs.
- `api.py` adapts use cases to HTTP only.
- `ui.py` adapts use cases to Tk only.
- `migrations.py` registers additive schema changes through
`unityscraper.core.db.MigrationRegistry`.

## Migration Order

1. Keep existing entry points working.
2. Add package-facing adapters for existing services.
3. Move pure models and parsing helpers first.
4. Move repositories and schema ownership next.
5. Move command handlers after services are UI-neutral.
6. Split large UI pages only after their services are stable.

## Feature Ownership

| Domain | Owns |
| --- | --- |
| `library` | XboxUnity titles, covers, title updates, local library search |
| `knowledge` | source-attributed facts, citations, conflicts, offline archive |
| `backups` | owned-content inventory, import, export, verification, FTP-safe flows |
| `profiles` | profile/save discovery, snapshots, restore, achievement inspection |
| `packages` | STFS, XEX, XBE inspection and read-only extraction |
| `collections` | preservation matching, DAT checks, health reports, repair previews |
| `console_sync` | console inventories, durable transfer plans, dashboard capabilities |
| `tools` | external executable catalog, argument templates, captured execution |
| `plugins` | opt-in collectors, trust state, isolated plugin runs |

## Long-Term AIO Rule

New Xbox 360 capabilities should enter as domain use cases first. Desktop
buttons, REST routes, and CLI flags should call those use cases rather than
owning file, database, FTP, or package logic themselves.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ Build the macOS application bundle on macOS:

- [Documentation index](DOCS_INDEX.md)
- [Architecture](ARCHITECTURE.md)
- [Modularization plan](MODULARIZATION_PLAN.md)
- [Linux support](LINUX.md)
- [macOS preview](MACOS.md)
- [Community Hub](COMMUNITY_HUB.md)
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ Homepage = "https://github.com/TrapEmAll/UnityScraper"
Issues = "https://github.com/TrapEmAll/UnityScraper/issues"
Releases = "https://github.com/TrapEmAll/UnityScraper/releases"

[project.scripts]
unityscraper = "unityscraper.app.desktop.entrypoint:main"
unityscraper-cli = "unityscraper.app.cli.entrypoint:main"

[tool.pytest.ini_options]
testpaths = ["tests.py"]
addopts = "-ra"
Expand Down
69 changes: 65 additions & 4 deletions tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json
import hashlib
import os
import sqlite3
import sys
import time
import zipfile
Expand Down Expand Up @@ -62,12 +63,16 @@
package_destination,
scan_local_target,
)
from backup_service import BackupRepository
from backup_service import BackupRepository, BackupService
from api import UnityScraperAPI
from app_version import DISPLAY_VERSION
from app_paths import resolve_storage_paths
from platform_support import desktop_font_family, path_opener_command
from profile_manager import ProfileSaveManager, find_content_root, mask_identifier
from unityscraper.core.db import MigrationRegistry
from unityscraper.core.jobs import JobProgress, JobResult
from unityscraper.domains.backups.service import BackupService as ModularBackupService
from unityscraper.domains.library.service import LibraryService as ModularLibraryService


class TestPlatformSupport(unittest.TestCase):
Expand Down Expand Up @@ -185,9 +190,64 @@ def test_linux_desktop_metadata_is_complete(self):
root.findtext("id"),
"io.github.trapemall.UnityScraper",
)


class TestConfig(unittest.TestCase):


class TestModularFoundation(unittest.TestCase):
"""Test package-level adapters that support the modular architecture."""

def test_domain_service_exports_preserve_existing_implementations(self):
self.assertIs(ModularBackupService, BackupService)
self.assertIs(ModularLibraryService, LibraryService)

def test_job_progress_percent_is_bounded(self):
self.assertEqual(
JobProgress(status="running", message="working", current=5, total=10).percent,
50.0,
)
self.assertEqual(
JobProgress(status="running", message="over", current=15, total=10).percent,
100.0,
)
self.assertIsNone(JobProgress(status="running", message="unknown").percent)

def test_job_result_factories_set_terminal_state(self):
completed = JobResult.completed("done", count=2)
failed = JobResult.failed("failed", reason="example")

self.assertEqual(completed.status, "completed")
self.assertEqual(completed.payload["count"], 2)
self.assertIsNotNone(completed.finished_at)
self.assertEqual(failed.status, "failed")
self.assertEqual(failed.payload["reason"], "example")
self.assertIsNotNone(failed.finished_at)

def test_domain_migration_registry_applies_once(self):
calls = []

def migration(connection):
calls.append("applied")
connection.execute("CREATE TABLE example_domain_table (id INTEGER PRIMARY KEY)")

registry = MigrationRegistry()
registry.register(domain="example", version=1, name="example schema", apply=migration)

with sqlite3.connect(":memory:") as connection:
first = registry.apply(connection)
second = registry.apply(connection)
table = connection.execute(
"""
SELECT name FROM sqlite_master
WHERE type = 'table' AND name = 'example_domain_table'
"""
).fetchone()

self.assertEqual([item.key for item in first], ["example:1"])
self.assertEqual(second, [])
self.assertEqual(calls, ["applied"])
self.assertIsNotNone(table)


class TestConfig(unittest.TestCase):
"""Test configuration management"""

def setUp(self):
Expand Down Expand Up @@ -2432,6 +2492,7 @@ def run_tests():

# Add all test classes
suite.addTests(loader.loadTestsFromTestCase(TestPlatformSupport))
suite.addTests(loader.loadTestsFromTestCase(TestModularFoundation))
suite.addTests(loader.loadTestsFromTestCase(TestConfig))
suite.addTests(loader.loadTestsFromTestCase(TestRateLimiter))
suite.addTests(loader.loadTestsFromTestCase(TestUnityScraper))
Expand Down
7 changes: 7 additions & 0 deletions unityscraper/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""UnityScraper application package.

The package namespace is the long-term home for new code. Top-level modules
remain as compatibility entry points while existing features move behind
domain-oriented packages.
"""

2 changes: 2 additions & 0 deletions unityscraper/app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Application entry-point adapters for desktop, CLI, and API surfaces."""

2 changes: 2 additions & 0 deletions unityscraper/app/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Local REST API application adapters."""

8 changes: 8 additions & 0 deletions unityscraper/app/api/entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""API application factory adapter for the package layout."""

from __future__ import annotations

from api import UnityScraperAPI

__all__ = ["UnityScraperAPI"]

2 changes: 2 additions & 0 deletions unityscraper/app/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Command-line application adapters."""

8 changes: 8 additions & 0 deletions unityscraper/app/cli/entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""CLI entry point for the package layout."""

from __future__ import annotations

from main import main

__all__ = ["main"]

2 changes: 2 additions & 0 deletions unityscraper/app/desktop/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Desktop application shell adapters."""

8 changes: 8 additions & 0 deletions unityscraper/app/desktop/entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Desktop entry point for the package layout."""

from __future__ import annotations

from desktop_app import main

__all__ = ["main"]

2 changes: 2 additions & 0 deletions unityscraper/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Shared infrastructure used across UnityScraper domains."""

18 changes: 18 additions & 0 deletions unityscraper/core/db/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Database contracts and migration helpers."""

from __future__ import annotations

from .migrations import (
DomainMigration,
MigrationRegistry,
RegisteredMigration,
apply_registered_migrations,
)

__all__ = [
"DomainMigration",
"MigrationRegistry",
"RegisteredMigration",
"apply_registered_migrations",
]

Loading