v0.5a Full-Stack Self-Hosted

MTG Collection Tracker — Platform Design Document

A self-hosted Magic: The Gathering collection and deck management platform. Syncs the complete Scryfall card database, provides advanced search and filtering, tracks collections with finish-level granularity, and supports deck building with multi-source import and cross-printing ownership validation.

API Endpoints
30+
5 resource routers
Card Database
300K+
All Scryfall printings
Search Filters
16
Name, CMC, colours, oracle…
Import Sources
5
4 sites + text paste
Colour Modes
4
Exact, any, all, at most
Deploy
Multi-Stage
Node build + Python runtime

1. Platform Overview

MTG Collection Tracker is a self-hosted web application for Magic: The Gathering players who want full control over their collection data. It maintains a local mirror of the Scryfall card database, provides powerful search and filtering, tracks card ownership with per-finish granularity, and includes a complete deck building workflow with import from popular deck-building sites.

Core value proposition:

  • Complete card database — Full Scryfall mirror with daily automated sync, including all printings, prices, and card faces
  • Finish-level tracking — Separate quantities for nonfoil, foil, and etched versions of each card
  • Cross-printing awareness — Deck ownership checks consider all printings of a card (via Oracle ID), not just the specific printing in the deck
  • Multi-source deck import — Import from AetherHub, MTGGoldfish, Moxfield, Archidekt, or paste Arena format text
  • Self-hosted & private — Your collection data stays on your infrastructure; no third-party accounts required
  • Modern full-stack — React + TypeScript frontend with FastAPI async backend, containerised via Docker

2. System Architecture

┌──────────────────────────────────────────────────────────────────┐
│                         DOCKER HOST                              │
│                                                                  │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │  mtg-tracker  (Python 3.11 + Static React Build)           │  │
│  │                                                            │  │
│  │  ┌─────────────────────┐    ┌───────────────────────────┐  │  │
│  │  │  FastAPI Backend     │    │  React SPA (Static)       │  │  │
│  │  │                      │    │                           │  │  │
│  │  │  /api/v1/cards      │    │  Tailwind CSS             │  │  │
│  │  │  /api/v1/collection │    │  Zustand state            │  │  │
│  │  │  /api/v1/decks      │    │  TanStack Query/Table     │  │  │
│  │  │  /api/v1/sets       │    │  Vite build output        │  │  │
│  │  │  /api/v1/sync       │    │                           │  │  │
│  │  └──────────┬───────────┘    └───────────────────────────┘  │  │
│  │             │                                               │  │
│  │  ┌──────────▼───────────┐    ┌───────────────────────────┐  │  │
│  │  │  SQLAlchemy (Async)  │    │  APScheduler              │  │  │
│  │  │  SQLite + WAL + FTS5 │    │  Daily midnight sync      │  │  │
│  │  └──────────┬───────────┘    └───────────────────────────┘  │  │
│  │             │                                               │  │
│  │  ┌──────────▼───────────────────────────────────────────┐   │  │
│  │  │  /app/data/mtg.db                                    │   │  │
│  │  │  (Persistent volume: cards, sets, collection, decks) │   │  │
│  │  └──────────────────────────────────────────────────────┘   │  │
│  └────────────────────────────────────────────────────────────┘  │
│                              │                                    │
└──────────────────────────────┼────────────────────────────────────┘
                               │ HTTPS (rate-limited)
                    ┌──────────▼──────────┐
                    │  Scryfall API        │
                    │  api.scryfall.com    │
                    │  Bulk data download  │
                    │  (~500 MB JSON)      │
                    └─────────────────────┘

2.1 Backend Stack

ComponentTechnologyPurpose
Web FrameworkFastAPI 0.109Async REST API with automatic OpenAPI docs
ASGI ServerUvicorn 0.27Production ASGI server with standard extras
ORMSQLAlchemy 2.0 (async)Async database access with selectinload for N+1 prevention
DatabaseSQLite + aiosqliteWAL mode for concurrent reads; FTS5 for full-text search
HTTP Clienthttpx 0.26Async streaming downloads from Scryfall
ValidationPydantic 2.5Request/response schemas with strict typing
Task SchedulingAPScheduler 3.10Daily midnight Scryfall sync via CronTrigger
JSON StreamingCustom parser + ijsonMemory-efficient parsing of 500 MB bulk downloads

2.2 Frontend Stack

ComponentTechnologyPurpose
FrameworkReact 18.2 + TypeScript 5.3Component-based UI with strict type checking
Build ToolVite 5.0Fast dev server with HMR; optimised production builds
StylingTailwind CSS 3.4Custom MTG colour palette (W, U, B, R, G, C, Gold)
Client StateZustand 4.4Lightweight store for UI state (views, filters, modals)
Server StateTanStack Query 5.17Caching, background refetch, mutation invalidation
TablesTanStack Table 8.11Headless table with sorting and pagination
HTTP ClientAxios 1.6API calls with base URL configuration
IconsLucide React 0.307Consistent icon set across the UI

2.3 Container Layout

Multi-stage DockerfileDocker
# Stage 1: Build frontend (Node 20 Alpine)
FROM node:20-alpine AS frontend-builder
  npm install → npm run build → /app/frontend/dist/

# Stage 2: Production runtime (Python 3.11 Slim)
FROM python:3.11-slim
  pip install requirements → copy backend code
  COPY --from=frontend-builder /app/frontend/dist ./static
  Uvicorn serves both API (/api/) and SPA (/) from single container

Single-container design: The frontend is built at image build time and served as static files by FastAPI. No separate web server or reverse proxy is needed within the container. The API and SPA share port 8080.

3. Data Model

3.1 Database Schema

┌──────────────────┐         ┌────────────────────┐
│       sets       │         │    sync_metadata    │
├──────────────────┤         ├────────────────────┤
│ code (PK)        │         │ key (PK)           │
│ name             │         │ value              │
│ set_type         │         │ updated_at         │
│ released_at      │         └────────────────────┘
│ card_count       │
│ icon_svg_uri     │         ┌────────────────────┐
└────────┬─────────┘         │    cards_fts       │
         │ 1:N               ├────────────────────┤
┌────────▼─────────┐         │ FTS5 virtual table │
│      cards       │         │ card_id, name,     │
├──────────────────┤         │ type_line,         │
│ id (PK)          │         │ oracle_text,       │
│ scryfall_id (UQ) │         │ keywords           │
│ oracle_id (IDX)  │         └────────────────────┘
│ name, cmc        │
│ color_identity   │◄─────────────────────────────────┐
│ set_code (FK)    │                                   │
│ type_line        │         ┌────────────────────┐    │
│ oracle_text      │    1:N  │ collection_entries  │    │
│ keywords, mana   │────────►├────────────────────┤    │
│ power, toughness │         │ id (PK)            │    │
│ rarity, finishes │         │ card_id (FK, IDX)  │    │
│ prices (usd/foil │         │ finish             │    │
│   /etched)       │         │ quantity           │    │
│ image_uri(s)     │         │ acquired_date      │    │
└──────────────────┘         │ notes              │    │
         │                   │ UQ(card_id, finish)│    │
         │ 1:N               └────────┬───────────┘    │
         │                            │ 1:N            │
┌────────▼─────────┐    ┌─────────────▼──────────┐     │
│      decks       │    │     deck_cards          │     │
├──────────────────┤    ├────────────────────────┤     │
│ id (PK)          │1:N │ id (PK)                │     │
│ name             │───►│ deck_id (FK)           │     │
│ description      │    │ card_id (FK) ───────────────►│
│ format           │    │ collection_entry_id(FK)│
│ import_source    │    │ quantity               │
│ import_url       │    │ board (main/side/cmd)  │
└──────────────────┘    └────────────────────────┘

3.2 Key Relationships

RelationshipTypeConstraintNotes
Set → Cards1:NFK on set_codeEach card belongs to one set printing
Card → CollectionEntries1:NFK on card_idOne entry per card+finish combo (unique constraint)
Deck → DeckCards1:NFK on deck_id, cascade deleteDeleting a deck removes all its card entries
DeckCard → CardN:1FK on card_idReferences the specific printing
DeckCard → CollectionEntryN:1FK on collection_entry_id, nullableOptional link to owned copy

Oracle ID for cross-printing: The oracle_id field groups all printings of the same logical card. When checking deck ownership, the system queries total owned quantity across all printings sharing the same Oracle ID — so owning a card from any set satisfies the deck requirement.

4. Scryfall Integration

4.1 Sync Lifecycle

App Startup
    │
    ▼
Is database empty? ──yes──► Full sync: Sets → Cards → FTS Index
    │                                         │
    no                                        │
    │                         ┌───────────────┘
    ▼                         ▼
Schedule daily sync      Sync complete
(APScheduler, 0:00)          │
    │                         ▼
    └──── triggers ──► Sync Sets
                           │
                           ▼
                       Fetch bulk data URI from /bulk-data
                           │
                           ▼
                       Stream download (~500 MB)
                           │
                           ▼
                       Parse JSON objects on-the-fly
                       (custom streaming parser)
                           │
                           ▼
                       Batch upsert (1000 cards/batch)
                           │
                           ▼
                       Rebuild FTS5 index
                           │
                           ▼
                       Update sync_metadata

4.2 Bulk Data Options

TypeConfig ValueSizeCardsUse Case
Default Cardsdefault_cards~500 MB~300,000All printings with prices — recommended for collection tracking
Oracle Cardsoracle_cards~50 MB~100,000One printing per card — lighter, faster sync

4.3 Streaming Parser

The bulk data file is a single JSON array containing hundreds of thousands of card objects. To avoid loading the entire file into memory, the sync service uses a custom streaming parser:

  • Downloads via httpx.stream() with chunked transfer
  • Tracks JSON brace depth to identify complete objects
  • Parses each object individually with json.loads()
  • Batches 1000 cards per database upsert operation
  • Supports progress callbacks for UI feedback

4.4 Card Transformation

Each Scryfall card object is transformed to the local schema:

  • Double-faced cards: Oracle text merged from both faces with // separator
  • Card faces: Power, toughness, and images extracted from face data when not at top level
  • JSON fields: color_identity, keywords, and finishes stored as JSON strings
  • Prices: USD, USD foil, and USD etched extracted from Scryfall’s prices object
  • Upsert: ON CONFLICT (scryfall_id) DO UPDATE ensures idempotent syncs

Rate limiting: The Scryfall API requires a minimum 100ms delay between requests. The sync service respects this via a configurable SCRYFALL_REQUEST_DELAY (default: 0.1s). Bulk downloads are single requests and do not require rate limiting.

6. Collection Management

The collection system tracks card ownership with per-finish granularity. Each collection entry represents a specific card + finish combination (e.g., “Lightning Bolt from M25, foil, qty 2”).

6.1 Finish Tracking

FinishDescriptionPrice Field
nonfoilStandard printingprices.usd
foilFoil / premium printingprices.usd_foil
etchedEtched foil (Commander sets)prices.usd_etched

A unique constraint on (card_id, finish) ensures one entry per card+finish combination. Adding the same card+finish again increments the quantity rather than creating a duplicate.

6.2 Collection Statistics

The stats endpoint aggregates collection data across multiple dimensions:

  • Total cards — Sum of all quantities
  • Unique cards — Count of distinct card IDs
  • Total value — Sum of (price × quantity) based on finish
  • Distribution by set — Card count per set code
  • Distribution by colour — Card count per colour identity
  • Distribution by rarity — Card count per rarity tier
  • Distribution by finish — Card count per finish type

6.3 Export

Collections can be exported in two formats:

  • JSON — Full collection entries with card details
  • CSV — Flat format suitable for spreadsheets or other tools

7. Deck Building

7.1 Deck Import

Decks can be imported from five sources:

SourceMethodBoard Support
AetherHubHTML parsingMain, Sideboard
MTGGoldfishHTML parsingMain, Sideboard
MoxfieldJSON APIMain, Sideboard, Commander, Maybe
ArchidektJSON APIMain, Sideboard, Commander, Maybe
Text (Arena format)Regex parsingMain, Sideboard (section headers)

The import process:

  1. Fetch the deck list from the source URL (or parse pasted text)
  2. For each card name, search the local database (exact match → case-insensitive fallback)
  3. When multiple printings exist, prefer the one already in the user’s collection
  4. Create the Deck and DeckCard records
  5. Return a summary: found_count, missing_count, in_collection_count

7.2 Ownership Validation

The deck validity endpoint (GET /api/v1/decks/{id}/validity) checks whether the user owns all cards needed for a deck. The check is cross-printing aware:

  • For each card in the deck, look up its oracle_id
  • Query total owned quantity across all printings with the same Oracle ID
  • Compare owned quantity against the required quantity in the deck
  • Return: is_complete (boolean), missing_cards[] (with names and quantities), total_missing

This means owning a card from any set satisfies the deck requirement — you don’t need the exact printing listed in the deck.

7.3 Board Zones

ZoneTypical SizeDescription
main60 / 99 / 100Main deck (format-dependent size)
sideboard0–15Sideboard cards
commander1–2Commander zone (EDH/Brawl)
maybeAnyConsideration list (not part of final deck)

7.3 Export Formats

FormatDescriptionExample Output
txtText format grouped by board zone4 Lightning Bolt
arenaMTG Arena-compatible export4 Lightning Bolt (M25) 141
jsonFull deck model as JSONComplete deck object with all card data

8. API Reference

8.1 Cards

MethodEndpointDescription
GET/api/v1/cardsSearch cards with 16 filter parameters; paginated response
GET/api/v1/cards/autocomplete?q=Name autocomplete (min 2 chars, max 20 results)
GET/api/v1/cards/{id}Get card by internal ID
GET/api/v1/cards/by-scryfall/{id}Get card by Scryfall UUID
GET/api/v1/cards/by-name/{name}Get card by exact name
GET/api/v1/cards/{id}/usageCard usage across collection & decks (all printings)

8.2 Collection

MethodEndpointDescription
GET/api/v1/collectionList collection entries with filters + finish + deck membership
POST/api/v1/collectionAdd card (card_id, finish, quantity); upserts on duplicate
PATCH/api/v1/collection/{id}Update quantity/notes (quantity=0 deletes the entry)
DELETE/api/v1/collection/{id}Remove collection entry
GET/api/v1/collection/statsAggregated statistics (total, value, distributions)
GET/api/v1/collection/exportExport as JSON or CSV

8.3 Decks

MethodEndpointDescription
GET/api/v1/decksList all decks with card data
POST/api/v1/decksCreate deck (name, description, format)
GET/api/v1/decks/{id}Get deck with all cards & collection status
PATCH/api/v1/decks/{id}Update deck metadata
DELETE/api/v1/decks/{id}Delete deck (cascades to deck_cards)
POST/api/v1/decks/{id}/cardsAdd single card to deck
POST/api/v1/decks/{id}/cards/bulkBulk add cards by IDs
PATCH/api/v1/decks/{id}/cards/{cid}Update card in deck (quantity, board)
DELETE/api/v1/decks/{id}/cards/{cid}Remove card from deck
GET/api/v1/decks/{id}/validityCross-printing ownership check
GET/api/v1/decks/{id}/exportExport as txt, arena, or json
POST/api/v1/decks/importImport from URL (auto-detects source)
POST/api/v1/decks/import-textImport from pasted Arena text

8.4 Sets

MethodEndpointDescription
GET/api/v1/setsList all sets (ordered by release date, filterable by name/type)
GET/api/v1/sets/{code}Get single set by code
GET/api/v1/sets/{code}/cardsGet all cards in set (paginated)

8.5 Sync

MethodEndpointDescription
GET/api/v1/sync/statusCurrent sync state, last sync time, DB stats
POST/api/v1/sync/cardsTrigger full card sync (background task)
POST/api/v1/sync/setsSync sets only (faster)
POST/api/v1/sync/ftsRebuild full-text search index

9. Frontend Architecture

9.1 State Management

The frontend uses a two-tier state approach:

LayerLibraryPurposeExamples
Client StateZustandUI state, user interactionsActive view, display mode, selected card/deck, modals
Server StateTanStack QueryRemote data, caching, syncCard search results, collection entries, deck data

Zustand stores:

  • appStore — Active view (search | collection | decks), display mode (table | grid), selected card/deck, modal visibility
  • filterStore — Active filter state with auto-reset of page on filter change

Query caching strategy:

  • Card search: 30s stale time (frequent filter changes)
  • Sets list: 5min stale time (rarely changes)
  • Collection stats: 60s stale time
  • All mutations invalidate related queries (collection, cards, decks, stats)

9.2 Component Structure

Frontend component treeReact/TypeScript
App
├── MainLayout
│   ├── Header                         # Branding, title
│   ├── Navigation                     # Search | Collection | Decks tabs
│   └── [Active View]
│       ├── SearchView
│       │   ├── FiltersPanel           # Collapsible filter controls
│       │   │   ├── TextSearchFilter   # Name + oracle text
│       │   │   ├── CMCFilter          # Min/max mana value
│       │   │   ├── ColorFilter        # W/U/B/R/G/C + mode selector
│       │   │   ├── SetFilter          # Set multiselect
│       │   │   ├── TypeFilter         # Card type tags
│       │   │   └── RarityFilter       # Rarity checkboxes
│       │   ├── ViewToggle             # Grid ↔ Table switch
│       │   ├── CardGrid / CardTable   # Results display
│       │   └── CardDetailModal        # Full card details + actions
│       ├── CollectionView
│       │   ├── FiltersPanel
│       │   ├── CollectionStats        # KPI cards (total, value, etc.)
│       │   ├── CollectionGrid/Table   # Entries with QuantityControls
│       │   └── Export controls
│       └── DecksView
│           ├── DeckList               # All decks with summaries
│           ├── DeckEditor             # Card management within a deck
│           ├── CreateDeckModal
│           ├── ImportDeckModal        # URL + text paste
│           └── AddToDeckModal         # Card → deck assignment

9.3 Custom Hooks

HookPurposeKey Behaviour
useCards(filters)Card searchAuto-refetches on filter change; 30s stale time
useCollection(filters, finish)Collection listingSupports finish filter + deck membership filter
useCollectionStats()Collection aggregates60s stale time
useDecks()Deck listing30s stale time; all decks with cards
useDeck(id)Single deckConditional fetch; includes collection ownership status
useDeckValidity(id)Ownership checkCross-printing aware; returns missing card list

9.4 Styling

Tailwind CSS is extended with a custom MTG colour palette:

tailwind.config.js (excerpt)JavaScript
colors: {
  mtg: {
    white:     "#f9faf4",   // Plains
    blue:      "#0e68ab",   // Island
    black:     "#150b00",   // Swamp
    red:       "#d3202a",   // Mountain
    green:     "#00733e",   // Forest
    colorless: "#cbc2bf",   // Wastes
    gold:      "#c9a84c",   // Multicolour
  }
}

10. Performance Optimisations

OptimisationLayerImpact
SQLite WAL modeDatabaseConcurrent reads during writes; no lock contention during sync
FTS5 virtual tableDatabaseSub-millisecond full-text card search vs sequential scan
Streaming JSON parserSync~30 MB peak memory during 500 MB bulk download
Batch upserts (1000/batch)SyncReduces SQLite transaction overhead by 1000x
SelectinloadORMEager loading prevents N+1 queries on card-collection joins
TanStack Query cachingFrontend30-300s stale times reduce API calls during navigation
Vite code splittingFrontendRoute-based chunks; optimised dependency pre-bundling
Multi-stage Docker buildInfrastructureFinal image excludes Node.js and build tools (~200 MB smaller)

11. Deployment

11.1 Docker Compose

docker-compose.ymlYAML
services:
  mtg-tracker:
    build: .
    ports:
      - "8085:8080"             # Host port varies by target
    volumes:
      - ./data:/app/data        # Persistent SQLite database
    environment:
      - DATABASE_URL=sqlite+aiosqlite:///./data/mtg.db
      - SYNC_ON_STARTUP=true
      - SCRYFALL_BULK_TYPE=default_cards
    restart: unless-stopped

11.2 Deploy Command

Standard deploy via rsync + docker composeBash
# From source host
./deploy.sh mtg-helper-stu dev     # → claude-dev:8085
./deploy.sh mtg-helper-stu prod    # → claude-prod:8080
./deploy.sh mtg-helper-speed prod  # → claude-prod:8090 (same source)

11.3 Data Persistence

The ./data volume mount persists the SQLite database across container rebuilds. This directory contains:

  • mtg.db — Main database (cards, sets, collection, decks, sync metadata, FTS index)
  • mtg.db-wal, mtg.db-shm — WAL mode auxiliary files

The deploy script excludes data/ from rsync to protect the running database.

11.4 First Launch

  1. Container starts, creates data/mtg.db if it doesn’t exist
  2. SQLAlchemy creates all tables and enables WAL mode
  3. If SYNC_ON_STARTUP=true and DB is empty, triggers full Scryfall sync
  4. Sets sync: ~2 seconds (fetches all MTG sets)
  5. Card sync: ~20-30 minutes (downloads and processes ~500 MB bulk data)
  6. FTS index rebuild: ~30 seconds
  7. Application is usable immediately; sync runs in background

12. Configuration

VariableDefaultDescription
DATABASE_URLsqlite+aiosqlite:///./data/mtg.dbSQLAlchemy database connection string
SYNC_ON_STARTUPtrueAuto-sync Scryfall data when DB is empty
SCRYFALL_BULK_TYPEdefault_cardsBulk data type: default_cards (all printings) or oracle_cards (unique only)
SCRYFALL_API_BASEhttps://api.scryfall.comScryfall API base URL
SCRYFALL_REQUEST_DELAY0.1Rate limit delay between Scryfall API requests (seconds)

12.1 Backend Dependencies

PackageVersionPurpose
fastapi0.109.0Async web framework with automatic OpenAPI docs
uvicorn[standard]0.27.0Production ASGI server
sqlalchemy2.0.25Async ORM with relationship loading
aiosqlite0.19.0Async SQLite database driver
httpx0.26.0Async HTTP client for Scryfall API
pydantic2.5.3Request/response validation
pydantic-settings2.1.0Environment variable configuration
apscheduler3.10.4Cron-style task scheduling
ijson3.2.3Streaming JSON parser
python-multipart0.0.6File upload support
alembic1.13.1Database migration framework

12.2 Frontend Dependencies

PackageVersionPurpose
react18.2.0UI component framework
typescript5.3.3Static type checking
vite5.0.12Build tool with HMR dev server
tailwindcss3.4.1Utility-first CSS with custom MTG palette
zustand4.4.7Lightweight client state management
@tanstack/react-query5.17.0Server state caching & synchronisation
@tanstack/react-table8.11.2Headless table with sorting/pagination
axios1.6.5HTTP client
lucide-react0.307.0Icon library

12.3 Code Quality

ToolScopeConfig
RuffPython linting + formattingTarget Python 3.11, line-length 100, select E/F/I/W/UP/B/SIM
PyrightPython type checkingBasic mode, Python 3.11
MyPyPython type checkingBasic config, ignore missing imports
ESLintTypeScript lintingFlat config with React plugin
PrettierFrontend formattingStandard config
TypeScriptFrontend type checkingStrict mode, ES2020 target