Compare commits
2 commits
0801ceee6a
...
56499d5af9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56499d5af9 | ||
|
|
54ba7496c6 |
29 changed files with 2708 additions and 980 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -21,3 +21,4 @@ Thumbs.db
|
||||||
*.csv
|
*.csv
|
||||||
*.dlens
|
*.dlens
|
||||||
*.apk
|
*.apk
|
||||||
|
debug/
|
||||||
|
|
|
||||||
40
.justfile
40
.justfile
|
|
@ -1,24 +1,46 @@
|
||||||
# Scry development commands
|
# Scry development commands
|
||||||
|
|
||||||
|
set shell := ["C:/Program Files/Git/usr/bin/bash.exe", "-c"]
|
||||||
|
set unstable := true
|
||||||
|
|
||||||
# Android SDK paths
|
# Android SDK paths
|
||||||
|
|
||||||
android_sdk := env_var('LOCALAPPDATA') / "Android/Sdk"
|
android_sdk := replace(env('LOCALAPPDATA'), '\', '/') / "Android/Sdk"
|
||||||
adb := android_sdk / "platform-tools/adb.exe"
|
adb := android_sdk / "platform-tools/adb.exe"
|
||||||
emulator := android_sdk / "emulator/emulator.exe"
|
emulator := android_sdk / "emulator/emulator.exe"
|
||||||
camera_virtual := "-camera-back virtualscene -virtualscene-poster wall=\"" + (justfile_directory() / "TestImages/reference_alpha/serra_angel.jpg") + "\""
|
camera_virtual := "-camera-back virtualscene -virtualscene-poster wall=\"" + (justfile_directory() / "TestImages/reference_alpha/serra_angel.jpg") + "\""
|
||||||
camera_webcam := "-camera-back webcam0 -camera-front webcam0"
|
camera_webcam := "-camera-back webcam0 -camera-front webcam0"
|
||||||
|
|
||||||
|
[private]
|
||||||
|
@default:
|
||||||
|
just --list
|
||||||
|
|
||||||
|
# Start emulator in background
|
||||||
emu camera="virtual":
|
emu camera="virtual":
|
||||||
{{ emulator }} -avd Pixel_6 {{ if camera == "virtual" { camera_virtual } else { camera_webcam } }} -gpu host &
|
{{ emulator }} -avd Pixel_6 {{ if camera == "virtual" { camera_virtual } else { camera_webcam } }} -no-snapshot-load -gpu host
|
||||||
|
|
||||||
# Kill the running emulator
|
# Kill the running emulator
|
||||||
emu-kill:
|
emu-kill:
|
||||||
{{ adb }} emu kill
|
{{ adb }} emu kill
|
||||||
|
|
||||||
# Wait for emulator to fully boot
|
# Wait for emulator to fully boot (timeout after 2 minutes)
|
||||||
|
[script]
|
||||||
emu-wait:
|
emu-wait:
|
||||||
@echo "Waiting for emulator to boot..."
|
# Wait for Android emulator to boot with timeout
|
||||||
@while [ "$({{ adb }} shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do sleep 1; done
|
TIMEOUT=120
|
||||||
@echo "Emulator ready"
|
|
||||||
|
echo "Waiting for emulator to boot..."
|
||||||
|
|
||||||
|
for ((i=TIMEOUT; i>0; i--)); do
|
||||||
|
if [ "$({{ adb }} shell getprop sys.boot_completed 2>/dev/null)" = "1" ]; then
|
||||||
|
echo "Emulator ready"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Emulator failed to boot within 2 minutes"
|
||||||
|
exit 1
|
||||||
|
|
||||||
# Build a project
|
# Build a project
|
||||||
build project="src/Scry.App" target="net10.0-android":
|
build project="src/Scry.App" target="net10.0-android":
|
||||||
|
|
@ -52,11 +74,11 @@ test:
|
||||||
dotnet test test/Scry.Tests
|
dotnet test test/Scry.Tests
|
||||||
|
|
||||||
# Generate the card hash database from Scryfall
|
# Generate the card hash database from Scryfall
|
||||||
gen-db *args: (build "tools/DbGenerator" "net10.0")
|
gen-db: (build "tools/DbGenerator" "net10.0")
|
||||||
@echo "Running Database generator (this takes a while)..."
|
@echo "Running Database generator (this takes a while)..."
|
||||||
dotnet run --project tools/DbGenerator --no-build -- src/Scry.App/Resources/Raw/card_hashes.db {{ args }}
|
dotnet run --project tools/DbGenerator --no-build -- src/Scry.App/Resources/Raw/card_hashes.db
|
||||||
@echo "Completed generating the database"
|
@echo "Completed generating the database"
|
||||||
|
|
||||||
# Full workflow: start emulator, wait, run with hot reload
|
# Full workflow: start emulator, wait, run with hot reload
|
||||||
dev: emu emu-wait
|
dev:
|
||||||
dotnet watch --project src/Scry.App -f net10.0-android
|
dotnet watch --project src/Scry.App -f net10.0-android
|
||||||
|
|
|
||||||
301
AGENTS.md
301
AGENTS.md
|
|
@ -1,30 +1,291 @@
|
||||||
# Agent Instructions
|
# Agent Instructions
|
||||||
|
|
||||||
## Build and Task Commands
|
## Overview
|
||||||
|
|
||||||
Prefer using `just` commands over raw dotnet/build commands:
|
Scry is a Magic: The Gathering card scanner app built with .NET MAUI. It uses perceptual hashing to match photographed cards against a database of known card images from Scryfall.
|
||||||
|
|
||||||
| Task | Command |
|
**Key components:**
|
||||||
|------|---------|
|
- Mobile scanning app (MAUI/Android)
|
||||||
| Build project | `just build` |
|
- Card recognition via perceptual hashing (not OCR)
|
||||||
| Run tests | `just test` |
|
- SQLite database with pre-computed hashes
|
||||||
| Generate card database | `just gen-db` |
|
- Scryfall API integration for card data
|
||||||
| Publish app | `just publish` |
|
|
||||||
| Run full dev workflow | `just dev` |
|
## Build Commands
|
||||||
|
|
||||||
|
Use `just` commands (defined in `.justfile`):
|
||||||
|
|
||||||
|
| Task | Command | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| Build project | `just build` | Builds Android debug |
|
||||||
|
| Run tests | `just test` | Runs xUnit tests |
|
||||||
|
| Generate card database | `just gen-db` | Downloads from Scryfall, computes hashes |
|
||||||
|
| Publish app | `just publish` | Creates release APK |
|
||||||
|
| Hot reload dev | `just dev` | Uses `dotnet watch` |
|
||||||
|
| Start emulator | `just emu` | Virtual camera with Serra Angel |
|
||||||
|
| Install to device | `just install` | Installs release APK |
|
||||||
|
|
||||||
|
### Database Generator Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just gen-db # Default: 500 cards with test images
|
||||||
|
dotnet run --project tools/DbGenerator -- -c 1000 # More cards
|
||||||
|
dotnet run --project tools/DbGenerator -- --force # Rebuild from scratch
|
||||||
|
dotnet run --project tools/DbGenerator -- --no-test-cards # Skip priority test cards
|
||||||
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
- `src/Scry.App` - MAUI mobile app
|
```
|
||||||
- `src/Scry.Core` - Core library (recognition, hashing, database)
|
src/
|
||||||
- `test/Scry.Tests` - Unit tests
|
├── Scry.App/ # MAUI mobile app (Android target)
|
||||||
- `tools/DbGenerator` - Card hash database generator
|
│ ├── Views/ # XAML pages (ScanPage, CollectionPage, etc.)
|
||||||
|
│ ├── ViewModels/ # MVVM ViewModels using CommunityToolkit.Mvvm
|
||||||
|
│ ├── Services/ # App-layer services (ICardRecognitionService, ICardRepository)
|
||||||
|
│ ├── Converters/ # XAML value converters
|
||||||
|
│ ├── Models/ # App-specific models (CollectionEntry)
|
||||||
|
│ └── Resources/Raw/ # Bundled card_hashes.db
|
||||||
|
│
|
||||||
|
└── Scry.Core/ # Platform-independent core library
|
||||||
|
├── Recognition/ # CardRecognitionService, RecognitionOptions
|
||||||
|
├── Imaging/ # PerceptualHash, ImagePreprocessor, CardDetector
|
||||||
|
├── Data/ # CardDatabase (SQLite)
|
||||||
|
├── Models/ # Card, Oracle, Set, ScanResult
|
||||||
|
└── Scryfall/ # ScryfallClient for API/bulk data
|
||||||
|
|
||||||
## Database Generator
|
test/
|
||||||
|
└── Scry.Tests/ # xUnit tests
|
||||||
|
|
||||||
The `just gen-db` command:
|
tools/
|
||||||
- Builds the DbGenerator tool
|
└── DbGenerator/ # CLI tool to generate card_hashes.db
|
||||||
- Runs it against `src/Scry.App/Resources/Raw/card_hashes.db`
|
|
||||||
- Supports incremental updates (only downloads missing cards)
|
|
||||||
- Prefers LEA/LEB (Alpha/Beta) sets for reference_alpha test cards
|
|
||||||
|
|
||||||
Use `--force` flag to rebuild from scratch if needed.
|
TestImages/ # Test images organized by category
|
||||||
|
├── reference_alpha/ # Alpha/Beta cards for testing
|
||||||
|
├── single_cards/ # Individual card photos
|
||||||
|
├── varying_quality/ # Different lighting/quality
|
||||||
|
├── hands/ # Cards held in hand
|
||||||
|
├── foil/ # Foil cards with glare
|
||||||
|
└── ... # More categories
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Recognition Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Camera Image
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ CardDetector │ ← Edge detection, find card quad
|
||||||
|
│ (optional) │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ PerspectiveCorrection│ ← Warp to rectangle
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ ImagePreprocessor │ ← CLAHE for lighting normalization
|
||||||
|
│ (ApplyClahe) │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ PerceptualHash │ ← Compute 192-bit color hash (24 bytes)
|
||||||
|
│ (ComputeColorHash) │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ CardRecognitionService│ ← Hamming distance match against DB
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
Three-table schema mirroring Scryfall:
|
||||||
|
|
||||||
|
- **oracles** - Abstract game cards (one per unique card name)
|
||||||
|
- **sets** - MTG sets with metadata
|
||||||
|
- **cards** - Printings with perceptual hashes (one per unique artwork)
|
||||||
|
|
||||||
|
The `Card` model includes denormalized Oracle fields for convenience.
|
||||||
|
|
||||||
|
### Key Classes
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `CardRecognitionService` | Main recognition logic, caches DB, handles rotation matching |
|
||||||
|
| `PerceptualHash` | DCT-based color hash (192-bit = 8 bytes × 3 RGB channels) |
|
||||||
|
| `ImagePreprocessor` | CLAHE, resize, grayscale conversions |
|
||||||
|
| `CardDetector` | Edge detection + contour analysis to find card boundaries |
|
||||||
|
| `PerspectiveCorrection` | Warp detected quad to rectangle |
|
||||||
|
| `CardDatabase` | SQLite wrapper with batch insert, queries |
|
||||||
|
| `ScryfallClient` | Bulk data streaming, image downloads |
|
||||||
|
|
||||||
|
## Code Conventions
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
- **Target**: .NET 10.0 (net10.0-android for app, net10.0 for Core/tools)
|
||||||
|
- **Nullable**: Enabled everywhere (`<Nullable>enable</Nullable>`)
|
||||||
|
- **Warnings as errors**: `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`
|
||||||
|
- **Central package management**: Versions in `Directory.Packages.props`
|
||||||
|
|
||||||
|
### C# Style
|
||||||
|
|
||||||
|
- Records for data models (`record Card`, `record ScanResult`)
|
||||||
|
- `required` properties for non-nullable required fields
|
||||||
|
- Extension methods for conversions (`ScryfallCard.ToCard()`)
|
||||||
|
- Static classes for pure functions (`PerceptualHash`, `ImagePreprocessor`)
|
||||||
|
- `using` declarations (not `using` blocks) for disposables
|
||||||
|
- File-scoped namespaces
|
||||||
|
- Primary constructors where appropriate
|
||||||
|
- `CancellationToken` parameter on all async methods
|
||||||
|
|
||||||
|
### MVVM (App layer)
|
||||||
|
|
||||||
|
- `CommunityToolkit.Mvvm` for source generators
|
||||||
|
- `[ObservableProperty]` attributes for bindable properties
|
||||||
|
- `[RelayCommand]` for commands
|
||||||
|
- ViewModels in `Scry.ViewModels` namespace
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
- Services: `ICardRecognitionService`, `CardRecognitionService`
|
||||||
|
- Database methods: `GetCardsWithHashAsync`, `InsertCardBatchAsync`
|
||||||
|
- Hash methods: `ComputeColorHash`, `HammingDistance`
|
||||||
|
- Test methods: `RecognizeAsync_ExactMatch_ReturnsSuccess`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests are in `test/Scry.Tests` using xUnit.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just test # Run all tests
|
||||||
|
dotnet test --filter "FullyQualifiedName~PerceptualHash" # Filter by name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Categories
|
||||||
|
|
||||||
|
| Test Class | Tests |
|
||||||
|
|------------|-------|
|
||||||
|
| `PerceptualHashTests` | Hash computation, Hamming distance |
|
||||||
|
| `CardRecognitionTests` | End-to-end recognition |
|
||||||
|
| `CardDatabaseTests` | SQLite CRUD operations |
|
||||||
|
| `ImagePreprocessorTests` | CLAHE, resize |
|
||||||
|
| `RobustnessAnalysisTests` | Multiple image variations |
|
||||||
|
|
||||||
|
### Test Images
|
||||||
|
|
||||||
|
TestImages directory contains categorized reference images:
|
||||||
|
- `reference_alpha/` - Alpha/Beta cards (matching DbGenerator priority cards)
|
||||||
|
- `single_cards/` - Clean single card photos
|
||||||
|
- `varying_quality/` - Different lighting/blur conditions
|
||||||
|
|
||||||
|
## Key Algorithms
|
||||||
|
|
||||||
|
### Perceptual Hash (pHash)
|
||||||
|
|
||||||
|
Color-aware 192-bit hash:
|
||||||
|
1. Resize to 32×32
|
||||||
|
2. For each RGB channel:
|
||||||
|
- Compute 2D DCT
|
||||||
|
- Extract 8×8 low-frequency coefficients (skip DC)
|
||||||
|
- Compare each to median → 63 bits per channel
|
||||||
|
3. Concatenate R, G, B hashes → 24 bytes (192 bits)
|
||||||
|
|
||||||
|
Matching uses Hamming distance with threshold ≤25 bits and minimum confidence 85%.
|
||||||
|
|
||||||
|
### CLAHE (Contrast Limited Adaptive Histogram Equalization)
|
||||||
|
|
||||||
|
Applied in LAB color space to L channel only:
|
||||||
|
- Tile-based histogram equalization (8×8 tiles)
|
||||||
|
- Clip limit prevents over-amplification of noise
|
||||||
|
- Bilinear interpolation between tiles for smooth output
|
||||||
|
|
||||||
|
### Card Detection
|
||||||
|
|
||||||
|
Pure SkiaSharp implementation:
|
||||||
|
1. Grayscale → Gaussian blur → Canny edge detection
|
||||||
|
2. Contour tracing via flood fill
|
||||||
|
3. Douglas-Peucker simplification → Convex hull
|
||||||
|
4. Find best quadrilateral matching MTG aspect ratio (88/63 ≈ 1.397)
|
||||||
|
5. Order corners: top-left, top-right, bottom-right, bottom-left
|
||||||
|
|
||||||
|
## Debug Mode
|
||||||
|
|
||||||
|
Set `RecognitionOptions.DebugOutputDirectory` to save pipeline stages:
|
||||||
|
- `01_input.png` - Original image
|
||||||
|
- `02_detection.png` - Card detection visualization
|
||||||
|
- `03_perspective_corrected.png` - Warped card
|
||||||
|
- `05_clahe_*.png` - After CLAHE preprocessing
|
||||||
|
|
||||||
|
On Android: `/sdcard/Download/scry-debug` (pull with `adb pull`)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Core Library (Scry.Core)
|
||||||
|
|
||||||
|
- **SkiaSharp** - Image processing, DCT, edge detection
|
||||||
|
- **Microsoft.Data.Sqlite** - SQLite database
|
||||||
|
- **Microsoft.Extensions.Options** - Options pattern
|
||||||
|
|
||||||
|
### App (Scry.App)
|
||||||
|
|
||||||
|
- **CommunityToolkit.Maui** - MAUI extensions
|
||||||
|
- **CommunityToolkit.Maui.Camera** - Camera integration
|
||||||
|
- **CommunityToolkit.Mvvm** - MVVM source generators
|
||||||
|
|
||||||
|
### DbGenerator Tool
|
||||||
|
|
||||||
|
- **Spectre.Console** / **Spectre.Console.Cli** - Rich terminal UI
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a New Card to Priority Test Set
|
||||||
|
|
||||||
|
1. Add image to `TestImages/reference_alpha/` or appropriate folder
|
||||||
|
2. Add entry to `GenerateCommand.PriorityCardsWithSets` dictionary
|
||||||
|
3. Run `just gen-db` to regenerate database
|
||||||
|
|
||||||
|
### Debugging Recognition Failures
|
||||||
|
|
||||||
|
1. Enable debug output in `MauiProgram.cs`:
|
||||||
|
```csharp
|
||||||
|
options.DebugOutputDirectory = "/sdcard/Download/scry-debug";
|
||||||
|
```
|
||||||
|
2. Run recognition
|
||||||
|
3. Pull debug images: `adb pull /sdcard/Download/scry-debug`
|
||||||
|
4. Compare `05_clahe_*.png` with reference images in database
|
||||||
|
|
||||||
|
### Modifying Hash Algorithm
|
||||||
|
|
||||||
|
1. Update `PerceptualHash.ComputeColorHash()`
|
||||||
|
2. Update `CardRecognitionService.ColorHashBits` constant
|
||||||
|
3. Regenerate database: `just gen-db --force`
|
||||||
|
4. Run tests: `just test`
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
1. **Hash size is 24 bytes (192 bits)** - 3 RGB channels × 8 bytes each
|
||||||
|
2. **Confidence threshold is 85%** - Configurable in `CardRecognitionService.MinConfidence`
|
||||||
|
3. **Card detection is optional** - Controlled by `RecognitionOptions.EnableCardDetection`
|
||||||
|
4. **Rotation matching tries 4 orientations** - Controlled by `RecognitionOptions.EnableRotationMatching`
|
||||||
|
5. **Database is bundled in APK** - Copied on first run to app data directory
|
||||||
|
6. **Multi-face cards** - Only front face image is used for hashing
|
||||||
|
7. **Rate limiting** - DbGenerator uses 50ms delay between Scryfall image downloads
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Forgejo Actions workflow (`.forgejo/workflows/release.yml`):
|
||||||
|
- Builds for win-x64, linux-x64, osx-x64
|
||||||
|
- Creates "standard" and "embedded" (with APK) variants
|
||||||
|
- Publishes to Forgejo releases
|
||||||
|
|
||||||
|
## External Resources
|
||||||
|
|
||||||
|
- [Scryfall API](https://scryfall.com/docs/api) - Card data source
|
||||||
|
- [CARD_RECOGNITION.md](docs/CARD_RECOGNITION.md) - Detailed architecture doc
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@
|
||||||
/>
|
/>
|
||||||
<PackageVersion Include="SkiaSharp" Version="3.119.0" />
|
<PackageVersion Include="SkiaSharp" Version="3.119.0" />
|
||||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.1" />
|
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.1" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Spectre.Console" Version="0.50.0" />
|
||||||
|
<PackageVersion Include="Spectre.Console.Cli" Version="0.50.0" />
|
||||||
|
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.2" />
|
<PackageVersion Include="xunit" Version="2.9.2" />
|
||||||
|
|
|
||||||
|
|
@ -313,22 +313,63 @@ public MatchResult Match(byte[] queryHash, CardDatabase db)
|
||||||
|
|
||||||
### Database Schema
|
### Database Schema
|
||||||
|
|
||||||
|
The schema mirrors Scryfall's data model with three main tables:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE cards (
|
-- Abstract game cards (oracle)
|
||||||
id TEXT PRIMARY KEY, -- Scryfall ID
|
CREATE TABLE oracles (
|
||||||
oracle_id TEXT NOT NULL,
|
id TEXT PRIMARY KEY, -- Scryfall oracle_id
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
set_code TEXT NOT NULL,
|
mana_cost TEXT,
|
||||||
collector_number TEXT,
|
cmc REAL,
|
||||||
illustration_id TEXT,
|
type_line TEXT,
|
||||||
image_url TEXT,
|
oracle_text TEXT,
|
||||||
art_hash BLOB, -- 256-bit hash of art region
|
colors TEXT, -- JSON array
|
||||||
full_hash BLOB, -- 256-bit hash of full card
|
color_identity TEXT, -- JSON array
|
||||||
color_hash BLOB -- 768-bit color-aware hash
|
keywords TEXT, -- JSON array
|
||||||
|
reserved INTEGER DEFAULT 0,
|
||||||
|
legalities TEXT, -- JSON object
|
||||||
|
power TEXT,
|
||||||
|
toughness TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_cards_oracle ON cards(oracle_id);
|
-- MTG sets
|
||||||
CREATE INDEX idx_cards_illustration ON cards(illustration_id);
|
CREATE TABLE sets (
|
||||||
|
id TEXT PRIMARY KEY, -- Scryfall set id
|
||||||
|
code TEXT NOT NULL UNIQUE, -- e.g., "lea", "mh2"
|
||||||
|
name TEXT NOT NULL, -- e.g., "Limited Edition Alpha"
|
||||||
|
set_type TEXT, -- e.g., "expansion", "core"
|
||||||
|
released_at TEXT,
|
||||||
|
card_count INTEGER,
|
||||||
|
icon_svg_uri TEXT,
|
||||||
|
digital INTEGER DEFAULT 0,
|
||||||
|
parent_set_code TEXT,
|
||||||
|
block TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Card printings with perceptual hashes
|
||||||
|
CREATE TABLE cards (
|
||||||
|
id TEXT PRIMARY KEY, -- Scryfall card ID (printing)
|
||||||
|
oracle_id TEXT NOT NULL, -- FK to oracles
|
||||||
|
set_id TEXT NOT NULL, -- FK to sets
|
||||||
|
set_code TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
collector_number TEXT,
|
||||||
|
rarity TEXT,
|
||||||
|
artist TEXT,
|
||||||
|
illustration_id TEXT, -- Same across printings with identical art
|
||||||
|
image_uri TEXT,
|
||||||
|
hash BLOB, -- Perceptual hash for matching
|
||||||
|
lang TEXT DEFAULT 'en',
|
||||||
|
prices_usd REAL,
|
||||||
|
prices_usd_foil REAL,
|
||||||
|
FOREIGN KEY (oracle_id) REFERENCES oracles(id),
|
||||||
|
FOREIGN KEY (set_id) REFERENCES sets(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_cards_oracle_id ON cards(oracle_id);
|
||||||
|
CREATE INDEX idx_cards_set_id ON cards(set_id);
|
||||||
|
CREATE INDEX idx_cards_name ON cards(name);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Phase 2: Enhanced (Add OCR Fallback)
|
### Phase 2: Enhanced (Add OCR Fallback)
|
||||||
|
|
|
||||||
BIN
screen.png
BIN
screen.png
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
|
|
@ -24,11 +24,22 @@ public static class MauiProgram
|
||||||
});
|
});
|
||||||
|
|
||||||
// Core Services (from Scry.Core)
|
// Core Services (from Scry.Core)
|
||||||
builder.Services.AddSingleton<CardHashDatabase>(sp =>
|
builder.Services.AddSingleton<CardDatabase>(sp =>
|
||||||
{
|
{
|
||||||
var dbPath = Path.Combine(FileSystem.AppDataDirectory, "card_hashes.db");
|
var dbPath = Path.Combine(FileSystem.AppDataDirectory, "card_hashes.db");
|
||||||
EnsureDatabaseCopied(dbPath);
|
EnsureDatabaseCopied(dbPath);
|
||||||
return new CardHashDatabase(dbPath);
|
return new CardDatabase(dbPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recognition options - configure debug output in DEBUG builds
|
||||||
|
builder.Services.Configure<RecognitionOptions>(options =>
|
||||||
|
{
|
||||||
|
#if DEBUG && ANDROID
|
||||||
|
// Use Download folder for easy adb pull access
|
||||||
|
options.DebugOutputDirectory = "/sdcard/Download/scry-debug";
|
||||||
|
#elif DEBUG
|
||||||
|
options.DebugOutputDirectory = "./debug";
|
||||||
|
#endif
|
||||||
});
|
});
|
||||||
builder.Services.AddSingleton<CardRecognitionService>();
|
builder.Services.AddSingleton<CardRecognitionService>();
|
||||||
|
|
||||||
|
|
@ -57,14 +68,25 @@ public static class MauiProgram
|
||||||
|
|
||||||
private static void EnsureDatabaseCopied(string targetPath)
|
private static void EnsureDatabaseCopied(string targetPath)
|
||||||
{
|
{
|
||||||
if (File.Exists(targetPath))
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var stream = FileSystem.OpenAppPackageFileAsync("card_hashes.db").GetAwaiter().GetResult();
|
using var bundledStream = FileSystem.OpenAppPackageFileAsync("card_hashes.db").GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
if (File.Exists(targetPath))
|
||||||
|
{
|
||||||
|
// Compare sizes - if bundled is larger, replace
|
||||||
|
var existingSize = new FileInfo(targetPath).Length;
|
||||||
|
var bundledSize = bundledStream.Length;
|
||||||
|
|
||||||
|
if (bundledSize <= existingSize)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Bundled db is larger, delete old and copy new
|
||||||
|
File.Delete(targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
using var fileStream = File.Create(targetPath);
|
using var fileStream = File.Create(targetPath);
|
||||||
stream.CopyTo(fileStream);
|
bundledStream.CopyTo(fileStream);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -29,6 +29,11 @@
|
||||||
<UseInterpreter>true</UseInterpreter>
|
<UseInterpreter>true</UseInterpreter>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Watch additional files for dotnet watch -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Watch Include="Resources\Raw\card_hashes.db" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<!-- Display name -->
|
<!-- Display name -->
|
||||||
<ApplicationTitle>Scry</ApplicationTitle>
|
<ApplicationTitle>Scry</ApplicationTitle>
|
||||||
|
|
|
||||||
|
|
@ -12,93 +12,99 @@ public class MockCardRecognitionService : ICardRecognitionService
|
||||||
[
|
[
|
||||||
new Card
|
new Card
|
||||||
{
|
{
|
||||||
Id = "sol-ring-c21",
|
Id = "4cbc6901-6a4a-4d0a-83ea-7eefa3b35021",
|
||||||
|
OracleId = "orb-sol-ring",
|
||||||
|
SetId = "set-c21",
|
||||||
Name = "Sol Ring",
|
Name = "Sol Ring",
|
||||||
SetCode = "C21",
|
SetCode = "C21",
|
||||||
SetName = "Commander 2021",
|
SetName = "Commander 2021",
|
||||||
CollectorNumber = "263",
|
CollectorNumber = "263",
|
||||||
ScryfallId = "4cbc6901-6a4a-4d0a-83ea-7eefa3b35021",
|
|
||||||
ImageUri = "https://cards.scryfall.io/normal/front/4/c/4cbc6901-6a4a-4d0a-83ea-7eefa3b35021.jpg",
|
ImageUri = "https://cards.scryfall.io/normal/front/4/c/4cbc6901-6a4a-4d0a-83ea-7eefa3b35021.jpg",
|
||||||
ManaCost = "{1}",
|
ManaCost = "{1}",
|
||||||
TypeLine = "Artifact",
|
TypeLine = "Artifact",
|
||||||
OracleText = "{T}: Add {C}{C}.",
|
OracleText = "{T}: Add {C}{C}.",
|
||||||
Rarity = "uncommon",
|
Rarity = "uncommon",
|
||||||
PriceUsd = 1.50m
|
PricesUsd = 1.50m
|
||||||
},
|
},
|
||||||
new Card
|
new Card
|
||||||
{
|
{
|
||||||
Id = "lightning-bolt-2xm",
|
Id = "e3285e6b-3e79-4d7c-bf96-d920f973b122",
|
||||||
|
OracleId = "orb-lightning-bolt",
|
||||||
|
SetId = "set-2xm",
|
||||||
Name = "Lightning Bolt",
|
Name = "Lightning Bolt",
|
||||||
SetCode = "2XM",
|
SetCode = "2XM",
|
||||||
SetName = "Double Masters",
|
SetName = "Double Masters",
|
||||||
CollectorNumber = "129",
|
CollectorNumber = "129",
|
||||||
ScryfallId = "e3285e6b-3e79-4d7c-bf96-d920f973b122",
|
|
||||||
ImageUri = "https://cards.scryfall.io/normal/front/e/3/e3285e6b-3e79-4d7c-bf96-d920f973b122.jpg",
|
ImageUri = "https://cards.scryfall.io/normal/front/e/3/e3285e6b-3e79-4d7c-bf96-d920f973b122.jpg",
|
||||||
ManaCost = "{R}",
|
ManaCost = "{R}",
|
||||||
TypeLine = "Instant",
|
TypeLine = "Instant",
|
||||||
OracleText = "Lightning Bolt deals 3 damage to any target.",
|
OracleText = "Lightning Bolt deals 3 damage to any target.",
|
||||||
Rarity = "uncommon",
|
Rarity = "uncommon",
|
||||||
PriceUsd = 2.00m
|
PricesUsd = 2.00m
|
||||||
},
|
},
|
||||||
new Card
|
new Card
|
||||||
{
|
{
|
||||||
Id = "counterspell-cmr",
|
Id = "ce30f926-bc06-46ee-9f35-0c32659a1b1c",
|
||||||
|
OracleId = "orb-counterspell",
|
||||||
|
SetId = "set-cmr",
|
||||||
Name = "Counterspell",
|
Name = "Counterspell",
|
||||||
SetCode = "CMR",
|
SetCode = "CMR",
|
||||||
SetName = "Commander Legends",
|
SetName = "Commander Legends",
|
||||||
CollectorNumber = "395",
|
CollectorNumber = "395",
|
||||||
ScryfallId = "ce30f926-bc06-46ee-9f35-0c32659a1b1c",
|
|
||||||
ImageUri = "https://cards.scryfall.io/normal/front/c/e/ce30f926-bc06-46ee-9f35-0c32659a1b1c.jpg",
|
ImageUri = "https://cards.scryfall.io/normal/front/c/e/ce30f926-bc06-46ee-9f35-0c32659a1b1c.jpg",
|
||||||
ManaCost = "{U}{U}",
|
ManaCost = "{U}{U}",
|
||||||
TypeLine = "Instant",
|
TypeLine = "Instant",
|
||||||
OracleText = "Counter target spell.",
|
OracleText = "Counter target spell.",
|
||||||
Rarity = "uncommon",
|
Rarity = "uncommon",
|
||||||
PriceUsd = 1.25m
|
PricesUsd = 1.25m
|
||||||
},
|
},
|
||||||
new Card
|
new Card
|
||||||
{
|
{
|
||||||
Id = "llanowar-elves-m19",
|
Id = "73542c66-eb3a-46e8-a8f6-5f02087b28cf",
|
||||||
|
OracleId = "orb-llanowar-elves",
|
||||||
|
SetId = "set-m19",
|
||||||
Name = "Llanowar Elves",
|
Name = "Llanowar Elves",
|
||||||
SetCode = "M19",
|
SetCode = "M19",
|
||||||
SetName = "Core Set 2019",
|
SetName = "Core Set 2019",
|
||||||
CollectorNumber = "314",
|
CollectorNumber = "314",
|
||||||
ScryfallId = "73542c66-eb3a-46e8-a8f6-5f02087b28cf",
|
|
||||||
ImageUri = "https://cards.scryfall.io/normal/front/7/3/73542c66-eb3a-46e8-a8f6-5f02087b28cf.jpg",
|
ImageUri = "https://cards.scryfall.io/normal/front/7/3/73542c66-eb3a-46e8-a8f6-5f02087b28cf.jpg",
|
||||||
ManaCost = "{G}",
|
ManaCost = "{G}",
|
||||||
TypeLine = "Creature — Elf Druid",
|
TypeLine = "Creature — Elf Druid",
|
||||||
OracleText = "{T}: Add {G}.",
|
OracleText = "{T}: Add {G}.",
|
||||||
Rarity = "common",
|
Rarity = "common",
|
||||||
PriceUsd = 0.25m
|
PricesUsd = 0.25m
|
||||||
},
|
},
|
||||||
new Card
|
new Card
|
||||||
{
|
{
|
||||||
Id = "swords-to-plowshares-cmr",
|
Id = "b8dcd4a6-5c0b-4e1e-8e1d-2e2e5c1d6a1e",
|
||||||
|
OracleId = "orb-swords-to-plowshares",
|
||||||
|
SetId = "set-cmr",
|
||||||
Name = "Swords to Plowshares",
|
Name = "Swords to Plowshares",
|
||||||
SetCode = "CMR",
|
SetCode = "CMR",
|
||||||
SetName = "Commander Legends",
|
SetName = "Commander Legends",
|
||||||
CollectorNumber = "387",
|
CollectorNumber = "387",
|
||||||
ScryfallId = "b8dcd4a6-5c0b-4e1e-8e1d-2e2e5c1d6a1e",
|
|
||||||
ImageUri = "https://cards.scryfall.io/normal/front/b/8/b8dcd4a6-5c0b-4e1e-8e1d-2e2e5c1d6a1e.jpg",
|
ImageUri = "https://cards.scryfall.io/normal/front/b/8/b8dcd4a6-5c0b-4e1e-8e1d-2e2e5c1d6a1e.jpg",
|
||||||
ManaCost = "{W}",
|
ManaCost = "{W}",
|
||||||
TypeLine = "Instant",
|
TypeLine = "Instant",
|
||||||
OracleText = "Exile target creature. Its controller gains life equal to its power.",
|
OracleText = "Exile target creature. Its controller gains life equal to its power.",
|
||||||
Rarity = "uncommon",
|
Rarity = "uncommon",
|
||||||
PriceUsd = 3.50m
|
PricesUsd = 3.50m
|
||||||
},
|
},
|
||||||
new Card
|
new Card
|
||||||
{
|
{
|
||||||
Id = "black-lotus-lea",
|
Id = "bd8fa327-dd41-4737-8f19-2cf5eb1f7c2e",
|
||||||
|
OracleId = "orb-black-lotus",
|
||||||
|
SetId = "set-lea",
|
||||||
Name = "Black Lotus",
|
Name = "Black Lotus",
|
||||||
SetCode = "LEA",
|
SetCode = "LEA",
|
||||||
SetName = "Limited Edition Alpha",
|
SetName = "Limited Edition Alpha",
|
||||||
CollectorNumber = "232",
|
CollectorNumber = "232",
|
||||||
ScryfallId = "bd8fa327-dd41-4737-8f19-2cf5eb1f7c2e",
|
|
||||||
ImageUri = "https://cards.scryfall.io/normal/front/b/d/bd8fa327-dd41-4737-8f19-2cf5eb1f7c2e.jpg",
|
ImageUri = "https://cards.scryfall.io/normal/front/b/d/bd8fa327-dd41-4737-8f19-2cf5eb1f7c2e.jpg",
|
||||||
ManaCost = "{0}",
|
ManaCost = "{0}",
|
||||||
TypeLine = "Artifact",
|
TypeLine = "Artifact",
|
||||||
OracleText = "{T}, Sacrifice Black Lotus: Add three mana of any one color.",
|
OracleText = "{T}, Sacrifice Black Lotus: Add three mana of any one color.",
|
||||||
Rarity = "rare",
|
Rarity = "rare",
|
||||||
PriceUsd = 500000.00m
|
PricesUsd = 500000.00m
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,21 @@ namespace Scry.ViewModels;
|
||||||
|
|
||||||
public partial class SettingsViewModel : ObservableObject
|
public partial class SettingsViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly CardHashDatabase _database;
|
private readonly CardDatabase _database;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private int _cardCount;
|
private int _cardCount;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int _oracleCount;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int _setCount;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? _statusMessage;
|
private string? _statusMessage;
|
||||||
|
|
||||||
public SettingsViewModel(CardHashDatabase database)
|
public SettingsViewModel(CardDatabase database)
|
||||||
{
|
{
|
||||||
_database = database;
|
_database = database;
|
||||||
}
|
}
|
||||||
|
|
@ -22,7 +28,9 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
CardCount = await _database.GetHashCountAsync();
|
CardCount = await _database.GetCardCountAsync();
|
||||||
StatusMessage = $"Database ready with {CardCount:N0} cards";
|
OracleCount = await _database.GetOracleCountAsync();
|
||||||
|
SetCount = await _database.GetSetCountAsync();
|
||||||
|
StatusMessage = $"Database ready: {CardCount:N0} cards, {OracleCount:N0} oracles, {SetCount:N0} sets";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
739
src/Scry.Core/Data/CardDatabase.cs
Normal file
739
src/Scry.Core/Data/CardDatabase.cs
Normal file
|
|
@ -0,0 +1,739 @@
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Scry.Core.Models;
|
||||||
|
|
||||||
|
namespace Scry.Core.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQLite database for storing card data and perceptual hashes.
|
||||||
|
/// Schema mirrors Scryfall's data model: oracles (game cards), sets, and cards (printings).
|
||||||
|
/// </summary>
|
||||||
|
public class CardDatabase : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection _connection;
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
public CardDatabase(string dbPath)
|
||||||
|
{
|
||||||
|
_dbPath = dbPath;
|
||||||
|
_connection = new SqliteConnection($"Data Source={dbPath}");
|
||||||
|
_connection.Open();
|
||||||
|
Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Initialize()
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
-- Abstract game cards (oracle)
|
||||||
|
CREATE TABLE IF NOT EXISTS oracles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
mana_cost TEXT,
|
||||||
|
cmc REAL,
|
||||||
|
type_line TEXT,
|
||||||
|
oracle_text TEXT,
|
||||||
|
colors TEXT,
|
||||||
|
color_identity TEXT,
|
||||||
|
keywords TEXT,
|
||||||
|
reserved INTEGER DEFAULT 0,
|
||||||
|
legalities TEXT,
|
||||||
|
power TEXT,
|
||||||
|
toughness TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oracles_name ON oracles(name);
|
||||||
|
|
||||||
|
-- MTG sets
|
||||||
|
CREATE TABLE IF NOT EXISTS sets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
set_type TEXT,
|
||||||
|
released_at TEXT,
|
||||||
|
card_count INTEGER,
|
||||||
|
icon_svg_uri TEXT,
|
||||||
|
digital INTEGER DEFAULT 0,
|
||||||
|
parent_set_code TEXT,
|
||||||
|
block TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sets_code ON sets(code);
|
||||||
|
|
||||||
|
-- Card printings with hashes
|
||||||
|
CREATE TABLE IF NOT EXISTS cards (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
oracle_id TEXT NOT NULL,
|
||||||
|
set_id TEXT NOT NULL,
|
||||||
|
set_code TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
collector_number TEXT,
|
||||||
|
rarity TEXT,
|
||||||
|
artist TEXT,
|
||||||
|
illustration_id TEXT,
|
||||||
|
image_uri TEXT,
|
||||||
|
hash BLOB,
|
||||||
|
lang TEXT DEFAULT 'en',
|
||||||
|
prices_usd REAL,
|
||||||
|
prices_usd_foil REAL,
|
||||||
|
FOREIGN KEY (oracle_id) REFERENCES oracles(id),
|
||||||
|
FOREIGN KEY (set_id) REFERENCES sets(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cards_oracle_id ON cards(oracle_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cards_set_id ON cards(set_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cards_name ON cards(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cards_set_code ON cards(set_code);
|
||||||
|
|
||||||
|
-- Metadata for tracking sync state
|
||||||
|
CREATE TABLE IF NOT EXISTS metadata (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Metadata
|
||||||
|
|
||||||
|
public async Task<string?> GetMetadataAsync(string key, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT value FROM metadata WHERE key = $key";
|
||||||
|
cmd.Parameters.AddWithValue("$key", key);
|
||||||
|
|
||||||
|
var result = await cmd.ExecuteScalarAsync(ct);
|
||||||
|
return result as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetMetadataAsync(string key, string value, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT OR REPLACE INTO metadata (key, value) VALUES ($key, $value)
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$key", key);
|
||||||
|
cmd.Parameters.AddWithValue("$value", value);
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Oracles
|
||||||
|
|
||||||
|
public async Task InsertOracleAsync(Oracle oracle, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT OR REPLACE INTO oracles
|
||||||
|
(id, name, mana_cost, cmc, type_line, oracle_text, colors, color_identity, keywords, reserved, legalities, power, toughness)
|
||||||
|
VALUES ($id, $name, $mana_cost, $cmc, $type_line, $oracle_text, $colors, $color_identity, $keywords, $reserved, $legalities, $power, $toughness)
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$id", oracle.Id);
|
||||||
|
cmd.Parameters.AddWithValue("$name", oracle.Name);
|
||||||
|
cmd.Parameters.AddWithValue("$mana_cost", oracle.ManaCost ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$cmc", oracle.Cmc ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$type_line", oracle.TypeLine ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$oracle_text", oracle.OracleText ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$colors", oracle.Colors ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$color_identity", oracle.ColorIdentity ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$keywords", oracle.Keywords ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$reserved", oracle.Reserved ? 1 : 0);
|
||||||
|
cmd.Parameters.AddWithValue("$legalities", oracle.Legalities ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$power", oracle.Power ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$toughness", oracle.Toughness ?? (object)DBNull.Value);
|
||||||
|
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InsertOracleBatchAsync(IEnumerable<Oracle> oracles, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var transaction = await _connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT OR REPLACE INTO oracles
|
||||||
|
(id, name, mana_cost, cmc, type_line, oracle_text, colors, color_identity, keywords, reserved, legalities, power, toughness)
|
||||||
|
VALUES ($id, $name, $mana_cost, $cmc, $type_line, $oracle_text, $colors, $color_identity, $keywords, $reserved, $legalities, $power, $toughness)
|
||||||
|
""";
|
||||||
|
|
||||||
|
var idParam = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||||
|
var nameParam = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||||
|
var manaCostParam = cmd.Parameters.Add("$mana_cost", SqliteType.Text);
|
||||||
|
var cmcParam = cmd.Parameters.Add("$cmc", SqliteType.Real);
|
||||||
|
var typeLineParam = cmd.Parameters.Add("$type_line", SqliteType.Text);
|
||||||
|
var oracleTextParam = cmd.Parameters.Add("$oracle_text", SqliteType.Text);
|
||||||
|
var colorsParam = cmd.Parameters.Add("$colors", SqliteType.Text);
|
||||||
|
var colorIdentityParam = cmd.Parameters.Add("$color_identity", SqliteType.Text);
|
||||||
|
var keywordsParam = cmd.Parameters.Add("$keywords", SqliteType.Text);
|
||||||
|
var reservedParam = cmd.Parameters.Add("$reserved", SqliteType.Integer);
|
||||||
|
var legalitiesParam = cmd.Parameters.Add("$legalities", SqliteType.Text);
|
||||||
|
var powerParam = cmd.Parameters.Add("$power", SqliteType.Text);
|
||||||
|
var toughnessParam = cmd.Parameters.Add("$toughness", SqliteType.Text);
|
||||||
|
|
||||||
|
foreach (var oracle in oracles)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
idParam.Value = oracle.Id;
|
||||||
|
nameParam.Value = oracle.Name;
|
||||||
|
manaCostParam.Value = oracle.ManaCost ?? (object)DBNull.Value;
|
||||||
|
cmcParam.Value = oracle.Cmc ?? (object)DBNull.Value;
|
||||||
|
typeLineParam.Value = oracle.TypeLine ?? (object)DBNull.Value;
|
||||||
|
oracleTextParam.Value = oracle.OracleText ?? (object)DBNull.Value;
|
||||||
|
colorsParam.Value = oracle.Colors ?? (object)DBNull.Value;
|
||||||
|
colorIdentityParam.Value = oracle.ColorIdentity ?? (object)DBNull.Value;
|
||||||
|
keywordsParam.Value = oracle.Keywords ?? (object)DBNull.Value;
|
||||||
|
reservedParam.Value = oracle.Reserved ? 1 : 0;
|
||||||
|
legalitiesParam.Value = oracle.Legalities ?? (object)DBNull.Value;
|
||||||
|
powerParam.Value = oracle.Power ?? (object)DBNull.Value;
|
||||||
|
toughnessParam.Value = oracle.Toughness ?? (object)DBNull.Value;
|
||||||
|
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Oracle?> GetOracleByIdAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT id, name, mana_cost, cmc, type_line, oracle_text, colors, color_identity, keywords, reserved, legalities, power, toughness
|
||||||
|
FROM oracles WHERE id = $id
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$id", id);
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
if (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
return ReadOracle(reader);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Oracle?> GetOracleByNameAsync(string name, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT id, name, mana_cost, cmc, type_line, oracle_text, colors, color_identity, keywords, reserved, legalities, power, toughness
|
||||||
|
FROM oracles WHERE name = $name COLLATE NOCASE
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$name", name);
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
if (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
return ReadOracle(reader);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HashSet<string>> GetExistingOracleIdsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ids = new HashSet<string>();
|
||||||
|
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT id FROM oracles";
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
ids.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetOracleCountAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT COUNT(*) FROM oracles";
|
||||||
|
var result = await cmd.ExecuteScalarAsync(ct);
|
||||||
|
return Convert.ToInt32(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Oracle ReadOracle(SqliteDataReader reader) => new()
|
||||||
|
{
|
||||||
|
Id = reader.GetString(0),
|
||||||
|
Name = reader.GetString(1),
|
||||||
|
ManaCost = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||||
|
Cmc = reader.IsDBNull(3) ? null : reader.GetDouble(3),
|
||||||
|
TypeLine = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||||
|
OracleText = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||||
|
Colors = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||||
|
ColorIdentity = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||||
|
Keywords = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||||
|
Reserved = reader.GetInt32(9) != 0,
|
||||||
|
Legalities = reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||||
|
Power = reader.IsDBNull(11) ? null : reader.GetString(11),
|
||||||
|
Toughness = reader.IsDBNull(12) ? null : reader.GetString(12),
|
||||||
|
};
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Sets
|
||||||
|
|
||||||
|
public async Task InsertSetAsync(Set set, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT OR REPLACE INTO sets
|
||||||
|
(id, code, name, set_type, released_at, card_count, icon_svg_uri, digital, parent_set_code, block)
|
||||||
|
VALUES ($id, $code, $name, $set_type, $released_at, $card_count, $icon_svg_uri, $digital, $parent_set_code, $block)
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$id", set.Id);
|
||||||
|
cmd.Parameters.AddWithValue("$code", set.Code);
|
||||||
|
cmd.Parameters.AddWithValue("$name", set.Name);
|
||||||
|
cmd.Parameters.AddWithValue("$set_type", set.SetType ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$released_at", set.ReleasedAt ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$card_count", set.CardCount ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$icon_svg_uri", set.IconSvgUri ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$digital", set.Digital ? 1 : 0);
|
||||||
|
cmd.Parameters.AddWithValue("$parent_set_code", set.ParentSetCode ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$block", set.Block ?? (object)DBNull.Value);
|
||||||
|
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InsertSetBatchAsync(IEnumerable<Set> sets, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var transaction = await _connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT OR REPLACE INTO sets
|
||||||
|
(id, code, name, set_type, released_at, card_count, icon_svg_uri, digital, parent_set_code, block)
|
||||||
|
VALUES ($id, $code, $name, $set_type, $released_at, $card_count, $icon_svg_uri, $digital, $parent_set_code, $block)
|
||||||
|
""";
|
||||||
|
|
||||||
|
var idParam = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||||
|
var codeParam = cmd.Parameters.Add("$code", SqliteType.Text);
|
||||||
|
var nameParam = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||||
|
var setTypeParam = cmd.Parameters.Add("$set_type", SqliteType.Text);
|
||||||
|
var releasedAtParam = cmd.Parameters.Add("$released_at", SqliteType.Text);
|
||||||
|
var cardCountParam = cmd.Parameters.Add("$card_count", SqliteType.Integer);
|
||||||
|
var iconSvgUriParam = cmd.Parameters.Add("$icon_svg_uri", SqliteType.Text);
|
||||||
|
var digitalParam = cmd.Parameters.Add("$digital", SqliteType.Integer);
|
||||||
|
var parentSetCodeParam = cmd.Parameters.Add("$parent_set_code", SqliteType.Text);
|
||||||
|
var blockParam = cmd.Parameters.Add("$block", SqliteType.Text);
|
||||||
|
|
||||||
|
foreach (var set in sets)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
idParam.Value = set.Id;
|
||||||
|
codeParam.Value = set.Code;
|
||||||
|
nameParam.Value = set.Name;
|
||||||
|
setTypeParam.Value = set.SetType ?? (object)DBNull.Value;
|
||||||
|
releasedAtParam.Value = set.ReleasedAt ?? (object)DBNull.Value;
|
||||||
|
cardCountParam.Value = set.CardCount ?? (object)DBNull.Value;
|
||||||
|
iconSvgUriParam.Value = set.IconSvgUri ?? (object)DBNull.Value;
|
||||||
|
digitalParam.Value = set.Digital ? 1 : 0;
|
||||||
|
parentSetCodeParam.Value = set.ParentSetCode ?? (object)DBNull.Value;
|
||||||
|
blockParam.Value = set.Block ?? (object)DBNull.Value;
|
||||||
|
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Set?> GetSetByIdAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT id, code, name, set_type, released_at, card_count, icon_svg_uri, digital, parent_set_code, block
|
||||||
|
FROM sets WHERE id = $id
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$id", id);
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
if (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
return ReadSet(reader);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Set?> GetSetByCodeAsync(string code, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT id, code, name, set_type, released_at, card_count, icon_svg_uri, digital, parent_set_code, block
|
||||||
|
FROM sets WHERE code = $code COLLATE NOCASE
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$code", code);
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
if (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
return ReadSet(reader);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HashSet<string>> GetExistingSetIdsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ids = new HashSet<string>();
|
||||||
|
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT id FROM sets";
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
ids.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetSetCountAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT COUNT(*) FROM sets";
|
||||||
|
var result = await cmd.ExecuteScalarAsync(ct);
|
||||||
|
return Convert.ToInt32(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set ReadSet(SqliteDataReader reader) => new()
|
||||||
|
{
|
||||||
|
Id = reader.GetString(0),
|
||||||
|
Code = reader.GetString(1),
|
||||||
|
Name = reader.GetString(2),
|
||||||
|
SetType = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||||
|
ReleasedAt = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||||
|
CardCount = reader.IsDBNull(5) ? null : reader.GetInt32(5),
|
||||||
|
IconSvgUri = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||||
|
Digital = reader.GetInt32(7) != 0,
|
||||||
|
ParentSetCode = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||||
|
Block = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||||
|
};
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Cards (Printings)
|
||||||
|
|
||||||
|
public async Task InsertCardAsync(Card card, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT OR REPLACE INTO cards
|
||||||
|
(id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil)
|
||||||
|
VALUES ($id, $oracle_id, $set_id, $set_code, $name, $collector_number, $rarity, $artist, $illustration_id, $image_uri, $hash, $lang, $prices_usd, $prices_usd_foil)
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$id", card.Id);
|
||||||
|
cmd.Parameters.AddWithValue("$oracle_id", card.OracleId);
|
||||||
|
cmd.Parameters.AddWithValue("$set_id", card.SetId);
|
||||||
|
cmd.Parameters.AddWithValue("$set_code", card.SetCode ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$name", card.Name);
|
||||||
|
cmd.Parameters.AddWithValue("$collector_number", card.CollectorNumber ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$rarity", card.Rarity ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$artist", card.Artist ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$illustration_id", card.IllustrationId ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$image_uri", card.ImageUri ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$hash", card.Hash ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$lang", card.Lang ?? "en");
|
||||||
|
cmd.Parameters.AddWithValue("$prices_usd", card.PricesUsd ?? (object)DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("$prices_usd_foil", card.PricesUsdFoil ?? (object)DBNull.Value);
|
||||||
|
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InsertCardBatchAsync(IEnumerable<Card> cards, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var transaction = await _connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT OR REPLACE INTO cards
|
||||||
|
(id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil)
|
||||||
|
VALUES ($id, $oracle_id, $set_id, $set_code, $name, $collector_number, $rarity, $artist, $illustration_id, $image_uri, $hash, $lang, $prices_usd, $prices_usd_foil)
|
||||||
|
""";
|
||||||
|
|
||||||
|
var idParam = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||||
|
var oracleIdParam = cmd.Parameters.Add("$oracle_id", SqliteType.Text);
|
||||||
|
var setIdParam = cmd.Parameters.Add("$set_id", SqliteType.Text);
|
||||||
|
var setCodeParam = cmd.Parameters.Add("$set_code", SqliteType.Text);
|
||||||
|
var nameParam = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||||
|
var collectorNumberParam = cmd.Parameters.Add("$collector_number", SqliteType.Text);
|
||||||
|
var rarityParam = cmd.Parameters.Add("$rarity", SqliteType.Text);
|
||||||
|
var artistParam = cmd.Parameters.Add("$artist", SqliteType.Text);
|
||||||
|
var illustrationIdParam = cmd.Parameters.Add("$illustration_id", SqliteType.Text);
|
||||||
|
var imageUriParam = cmd.Parameters.Add("$image_uri", SqliteType.Text);
|
||||||
|
var hashParam = cmd.Parameters.Add("$hash", SqliteType.Blob);
|
||||||
|
var langParam = cmd.Parameters.Add("$lang", SqliteType.Text);
|
||||||
|
var pricesUsdParam = cmd.Parameters.Add("$prices_usd", SqliteType.Real);
|
||||||
|
var pricesUsdFoilParam = cmd.Parameters.Add("$prices_usd_foil", SqliteType.Real);
|
||||||
|
|
||||||
|
foreach (var card in cards)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
idParam.Value = card.Id;
|
||||||
|
oracleIdParam.Value = card.OracleId;
|
||||||
|
setIdParam.Value = card.SetId;
|
||||||
|
setCodeParam.Value = card.SetCode ?? (object)DBNull.Value;
|
||||||
|
nameParam.Value = card.Name;
|
||||||
|
collectorNumberParam.Value = card.CollectorNumber ?? (object)DBNull.Value;
|
||||||
|
rarityParam.Value = card.Rarity ?? (object)DBNull.Value;
|
||||||
|
artistParam.Value = card.Artist ?? (object)DBNull.Value;
|
||||||
|
illustrationIdParam.Value = card.IllustrationId ?? (object)DBNull.Value;
|
||||||
|
imageUriParam.Value = card.ImageUri ?? (object)DBNull.Value;
|
||||||
|
hashParam.Value = card.Hash ?? (object)DBNull.Value;
|
||||||
|
langParam.Value = card.Lang ?? "en";
|
||||||
|
pricesUsdParam.Value = card.PricesUsd ?? (object)DBNull.Value;
|
||||||
|
pricesUsdFoilParam.Value = card.PricesUsdFoil ?? (object)DBNull.Value;
|
||||||
|
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Card?> GetCardByIdAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil
|
||||||
|
FROM cards WHERE id = $id
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$id", id);
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
if (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
return ReadCard(reader);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Card>> GetCardsByOracleIdAsync(string oracleId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var cards = new List<Card>();
|
||||||
|
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil
|
||||||
|
FROM cards WHERE oracle_id = $oracle_id
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$oracle_id", oracleId);
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
cards.Add(ReadCard(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Card>> GetCardsByNameAsync(string name, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var cards = new List<Card>();
|
||||||
|
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil
|
||||||
|
FROM cards WHERE name = $name COLLATE NOCASE
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$name", name);
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
cards.Add(ReadCard(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Card>> GetAllCardsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var cards = new List<Card>();
|
||||||
|
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil
|
||||||
|
FROM cards
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
cards.Add(ReadCard(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Card>> GetCardsWithHashAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var cards = new List<Card>();
|
||||||
|
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
// Join with oracles and sets to get denormalized data
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT c.id, c.oracle_id, c.set_id, c.set_code, c.name, c.collector_number, c.rarity, c.artist,
|
||||||
|
c.illustration_id, c.image_uri, c.hash, c.lang, c.prices_usd, c.prices_usd_foil,
|
||||||
|
o.mana_cost, o.type_line, o.oracle_text, o.power, o.toughness,
|
||||||
|
s.name as set_name
|
||||||
|
FROM cards c
|
||||||
|
LEFT JOIN oracles o ON c.oracle_id = o.id
|
||||||
|
LEFT JOIN sets s ON c.set_id = s.id
|
||||||
|
WHERE c.hash IS NOT NULL
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
cards.Add(ReadCardWithOracle(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HashSet<string>> GetExistingCardIdsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ids = new HashSet<string>();
|
||||||
|
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT id FROM cards";
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
ids.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HashSet<string>> GetExistingCardNamesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT DISTINCT name FROM cards";
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
names.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetCardCountAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT COUNT(*) FROM cards";
|
||||||
|
var result = await cmd.ExecuteScalarAsync(ct);
|
||||||
|
return Convert.ToInt32(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteCardByIdAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM cards WHERE id = $id";
|
||||||
|
cmd.Parameters.AddWithValue("$id", id);
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ClearCardsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM cards";
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ClearAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
DELETE FROM cards;
|
||||||
|
DELETE FROM oracles;
|
||||||
|
DELETE FROM sets;
|
||||||
|
DELETE FROM metadata;
|
||||||
|
""";
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Card ReadCard(SqliteDataReader reader) => new()
|
||||||
|
{
|
||||||
|
Id = reader.GetString(0),
|
||||||
|
OracleId = reader.GetString(1),
|
||||||
|
SetId = reader.GetString(2),
|
||||||
|
SetCode = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||||
|
Name = reader.GetString(4),
|
||||||
|
CollectorNumber = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||||
|
Rarity = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||||
|
Artist = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||||
|
IllustrationId = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||||
|
ImageUri = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||||
|
Hash = reader.IsDBNull(10) ? null : (byte[])reader.GetValue(10),
|
||||||
|
Lang = reader.IsDBNull(11) ? null : reader.GetString(11),
|
||||||
|
PricesUsd = reader.IsDBNull(12) ? null : (decimal)reader.GetDouble(12),
|
||||||
|
PricesUsdFoil = reader.IsDBNull(13) ? null : (decimal)reader.GetDouble(13),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a card with joined Oracle and Set data (columns 14-19).
|
||||||
|
/// </summary>
|
||||||
|
private static Card ReadCardWithOracle(SqliteDataReader reader) => new()
|
||||||
|
{
|
||||||
|
Id = reader.GetString(0),
|
||||||
|
OracleId = reader.GetString(1),
|
||||||
|
SetId = reader.GetString(2),
|
||||||
|
SetCode = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||||
|
Name = reader.GetString(4),
|
||||||
|
CollectorNumber = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||||
|
Rarity = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||||
|
Artist = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||||
|
IllustrationId = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||||
|
ImageUri = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||||
|
Hash = reader.IsDBNull(10) ? null : (byte[])reader.GetValue(10),
|
||||||
|
Lang = reader.IsDBNull(11) ? null : reader.GetString(11),
|
||||||
|
PricesUsd = reader.IsDBNull(12) ? null : (decimal)reader.GetDouble(12),
|
||||||
|
PricesUsdFoil = reader.IsDBNull(13) ? null : (decimal)reader.GetDouble(13),
|
||||||
|
// Denormalized Oracle fields (from JOIN)
|
||||||
|
ManaCost = reader.IsDBNull(14) ? null : reader.GetString(14),
|
||||||
|
TypeLine = reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||||
|
OracleText = reader.IsDBNull(16) ? null : reader.GetString(16),
|
||||||
|
Power = reader.IsDBNull(17) ? null : reader.GetString(17),
|
||||||
|
Toughness = reader.IsDBNull(18) ? null : reader.GetString(18),
|
||||||
|
SetName = reader.IsDBNull(19) ? null : reader.GetString(19),
|
||||||
|
};
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_connection.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,234 +0,0 @@
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Scry.Core.Models;
|
|
||||||
|
|
||||||
namespace Scry.Core.Data;
|
|
||||||
|
|
||||||
public class CardHashDatabase : IDisposable
|
|
||||||
{
|
|
||||||
private readonly SqliteConnection _connection;
|
|
||||||
private readonly string _dbPath;
|
|
||||||
|
|
||||||
public CardHashDatabase(string dbPath)
|
|
||||||
{
|
|
||||||
_dbPath = dbPath;
|
|
||||||
_connection = new SqliteConnection($"Data Source={dbPath}");
|
|
||||||
_connection.Open();
|
|
||||||
Initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Initialize()
|
|
||||||
{
|
|
||||||
using var cmd = _connection.CreateCommand();
|
|
||||||
cmd.CommandText = """
|
|
||||||
CREATE TABLE IF NOT EXISTS card_hashes (
|
|
||||||
card_id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
set_code TEXT NOT NULL,
|
|
||||||
collector_number TEXT,
|
|
||||||
hash BLOB NOT NULL,
|
|
||||||
image_uri TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_card_hashes_name ON card_hashes(name);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_card_hashes_set ON card_hashes(set_code);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS metadata (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
);
|
|
||||||
""";
|
|
||||||
cmd.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string?> GetMetadataAsync(string key, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await using var cmd = _connection.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT value FROM metadata WHERE key = $key";
|
|
||||||
cmd.Parameters.AddWithValue("$key", key);
|
|
||||||
|
|
||||||
var result = await cmd.ExecuteScalarAsync(ct);
|
|
||||||
return result as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SetMetadataAsync(string key, string value, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await using var cmd = _connection.CreateCommand();
|
|
||||||
cmd.CommandText = """
|
|
||||||
INSERT OR REPLACE INTO metadata (key, value) VALUES ($key, $value)
|
|
||||||
""";
|
|
||||||
cmd.Parameters.AddWithValue("$key", key);
|
|
||||||
cmd.Parameters.AddWithValue("$value", value);
|
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InsertHashAsync(CardHash hash, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await using var cmd = _connection.CreateCommand();
|
|
||||||
cmd.CommandText = """
|
|
||||||
INSERT OR REPLACE INTO card_hashes
|
|
||||||
(card_id, name, set_code, collector_number, hash, image_uri)
|
|
||||||
VALUES ($card_id, $name, $set_code, $collector_number, $hash, $image_uri)
|
|
||||||
""";
|
|
||||||
cmd.Parameters.AddWithValue("$card_id", hash.CardId);
|
|
||||||
cmd.Parameters.AddWithValue("$name", hash.Name);
|
|
||||||
cmd.Parameters.AddWithValue("$set_code", hash.SetCode);
|
|
||||||
cmd.Parameters.AddWithValue("$collector_number", hash.CollectorNumber ?? (object)DBNull.Value);
|
|
||||||
cmd.Parameters.AddWithValue("$hash", hash.Hash);
|
|
||||||
cmd.Parameters.AddWithValue("$image_uri", hash.ImageUri ?? (object)DBNull.Value);
|
|
||||||
|
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InsertHashBatchAsync(IEnumerable<CardHash> hashes, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await using var transaction = await _connection.BeginTransactionAsync(ct);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var cmd = _connection.CreateCommand();
|
|
||||||
cmd.CommandText = """
|
|
||||||
INSERT OR REPLACE INTO card_hashes
|
|
||||||
(card_id, name, set_code, collector_number, hash, image_uri)
|
|
||||||
VALUES ($card_id, $name, $set_code, $collector_number, $hash, $image_uri)
|
|
||||||
""";
|
|
||||||
|
|
||||||
var cardIdParam = cmd.Parameters.Add("$card_id", SqliteType.Text);
|
|
||||||
var nameParam = cmd.Parameters.Add("$name", SqliteType.Text);
|
|
||||||
var setCodeParam = cmd.Parameters.Add("$set_code", SqliteType.Text);
|
|
||||||
var collectorNumberParam = cmd.Parameters.Add("$collector_number", SqliteType.Text);
|
|
||||||
var hashParam = cmd.Parameters.Add("$hash", SqliteType.Blob);
|
|
||||||
var imageUriParam = cmd.Parameters.Add("$image_uri", SqliteType.Text);
|
|
||||||
|
|
||||||
foreach (var hash in hashes)
|
|
||||||
{
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
cardIdParam.Value = hash.CardId;
|
|
||||||
nameParam.Value = hash.Name;
|
|
||||||
setCodeParam.Value = hash.SetCode;
|
|
||||||
collectorNumberParam.Value = hash.CollectorNumber ?? (object)DBNull.Value;
|
|
||||||
hashParam.Value = hash.Hash;
|
|
||||||
imageUriParam.Value = hash.ImageUri ?? (object)DBNull.Value;
|
|
||||||
|
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
await transaction.RollbackAsync(ct);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<CardHash>> GetAllHashesAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var hashes = new List<CardHash>();
|
|
||||||
|
|
||||||
await using var cmd = _connection.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT card_id, name, set_code, collector_number, hash, image_uri FROM card_hashes";
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
|
||||||
while (await reader.ReadAsync(ct))
|
|
||||||
{
|
|
||||||
hashes.Add(new CardHash
|
|
||||||
{
|
|
||||||
CardId = reader.GetString(0),
|
|
||||||
Name = reader.GetString(1),
|
|
||||||
SetCode = reader.GetString(2),
|
|
||||||
CollectorNumber = reader.IsDBNull(3) ? null : reader.GetString(3),
|
|
||||||
Hash = (byte[])reader.GetValue(4),
|
|
||||||
ImageUri = reader.IsDBNull(5) ? null : reader.GetString(5)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return hashes;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> GetHashCountAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await using var cmd = _connection.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT COUNT(*) FROM card_hashes";
|
|
||||||
var result = await cmd.ExecuteScalarAsync(ct);
|
|
||||||
return Convert.ToInt32(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<CardHash?> GetHashByIdAsync(string cardId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await using var cmd = _connection.CreateCommand();
|
|
||||||
cmd.CommandText = """
|
|
||||||
SELECT card_id, name, set_code, collector_number, hash, image_uri
|
|
||||||
FROM card_hashes WHERE card_id = $card_id
|
|
||||||
""";
|
|
||||||
cmd.Parameters.AddWithValue("$card_id", cardId);
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
|
||||||
if (await reader.ReadAsync(ct))
|
|
||||||
{
|
|
||||||
return new CardHash
|
|
||||||
{
|
|
||||||
CardId = reader.GetString(0),
|
|
||||||
Name = reader.GetString(1),
|
|
||||||
SetCode = reader.GetString(2),
|
|
||||||
CollectorNumber = reader.IsDBNull(3) ? null : reader.GetString(3),
|
|
||||||
Hash = (byte[])reader.GetValue(4),
|
|
||||||
ImageUri = reader.IsDBNull(5) ? null : reader.GetString(5)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<HashSet<string>> GetExistingCardIdsAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var ids = new HashSet<string>();
|
|
||||||
|
|
||||||
await using var cmd = _connection.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT card_id FROM card_hashes";
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
|
||||||
while (await reader.ReadAsync(ct))
|
|
||||||
{
|
|
||||||
ids.Add(reader.GetString(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<HashSet<string>> GetExistingCardNamesAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
await using var cmd = _connection.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT DISTINCT name FROM card_hashes";
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
|
||||||
while (await reader.ReadAsync(ct))
|
|
||||||
{
|
|
||||||
names.Add(reader.GetString(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
return names;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteByCardIdAsync(string cardId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await using var cmd = _connection.CreateCommand();
|
|
||||||
cmd.CommandText = "DELETE FROM card_hashes WHERE card_id = $card_id";
|
|
||||||
cmd.Parameters.AddWithValue("$card_id", cardId);
|
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ClearAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await using var cmd = _connection.CreateCommand();
|
|
||||||
cmd.CommandText = "DELETE FROM card_hashes";
|
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_connection.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +1,143 @@
|
||||||
namespace Scry.Core.Models;
|
namespace Scry.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a specific printing of a card in a set.
|
||||||
|
/// Maps to Scryfall's Card object (which is really a printing).
|
||||||
|
/// Contains the perceptual hash for image matching.
|
||||||
|
/// Includes denormalized Oracle data for convenience.
|
||||||
|
/// </summary>
|
||||||
public record Card
|
public record Card
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scryfall's unique card ID for this specific printing.
|
||||||
|
/// </summary>
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required string Name { get; init; }
|
|
||||||
public string? SetCode { get; init; }
|
|
||||||
public string? SetName { get; init; }
|
|
||||||
public string? CollectorNumber { get; init; }
|
|
||||||
public string? ScryfallId { get; init; }
|
|
||||||
public string? Rarity { get; init; }
|
|
||||||
public string? ManaCost { get; init; }
|
|
||||||
public string? TypeLine { get; init; }
|
|
||||||
public string? OracleText { get; init; }
|
|
||||||
public string? ImageUri { get; init; }
|
|
||||||
public string? ImageUriSmall { get; init; }
|
|
||||||
public string? ImageUriLarge { get; init; }
|
|
||||||
public string? Artist { get; init; }
|
|
||||||
public string? Lang { get; init; }
|
|
||||||
public decimal? PriceUsd { get; init; }
|
|
||||||
public decimal? PriceUsdFoil { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Alias for ImageUri for compatibility with App layer
|
/// Oracle ID linking to the abstract game card.
|
||||||
|
/// </summary>
|
||||||
|
public required string OracleId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set ID this printing belongs to.
|
||||||
|
/// </summary>
|
||||||
|
public required string SetId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set code (e.g., "lea", "mh2") - denormalized for convenience.
|
||||||
|
/// </summary>
|
||||||
|
public string? SetCode { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set name - denormalized for convenience.
|
||||||
|
/// </summary>
|
||||||
|
public string? SetName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Card name - denormalized from Oracle for convenience.
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collector number within the set.
|
||||||
|
/// </summary>
|
||||||
|
public string? CollectorNumber { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rarity (common, uncommon, rare, mythic).
|
||||||
|
/// </summary>
|
||||||
|
public string? Rarity { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Artist name.
|
||||||
|
/// </summary>
|
||||||
|
public string? Artist { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Illustration ID - same across printings with identical art.
|
||||||
|
/// </summary>
|
||||||
|
public string? IllustrationId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URI to the card image (normal size).
|
||||||
|
/// </summary>
|
||||||
|
public string? ImageUri { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Perceptual hash for image matching.
|
||||||
|
/// </summary>
|
||||||
|
public byte[]? Hash { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Language code (e.g., "en", "ja").
|
||||||
|
/// </summary>
|
||||||
|
public string? Lang { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// USD price for non-foil.
|
||||||
|
/// </summary>
|
||||||
|
public decimal? PricesUsd { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// USD price for foil.
|
||||||
|
/// </summary>
|
||||||
|
public decimal? PricesUsdFoil { get; init; }
|
||||||
|
|
||||||
|
#region Denormalized Oracle Fields (for App layer convenience)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mana cost in Scryfall notation (e.g., "{2}{U}{U}").
|
||||||
|
/// Denormalized from Oracle.
|
||||||
|
/// </summary>
|
||||||
|
public string? ManaCost { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full type line (e.g., "Legendary Creature — Human Wizard").
|
||||||
|
/// Denormalized from Oracle.
|
||||||
|
/// </summary>
|
||||||
|
public string? TypeLine { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Official Oracle rules text.
|
||||||
|
/// Denormalized from Oracle.
|
||||||
|
/// </summary>
|
||||||
|
public string? OracleText { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Power for creatures (may contain non-numeric values like "*").
|
||||||
|
/// Denormalized from Oracle.
|
||||||
|
/// </summary>
|
||||||
|
public string? Power { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toughness for creatures (may contain non-numeric values like "*").
|
||||||
|
/// Denormalized from Oracle.
|
||||||
|
/// </summary>
|
||||||
|
public string? Toughness { get; init; }
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Compatibility Aliases
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Alias for ImageUri for compatibility.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? ImageUrl => ImageUri;
|
public string? ImageUrl => ImageUri;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Alias for PriceUsd for compatibility with App layer
|
/// Alias for PricesUsd for compatibility.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal? Price => PriceUsd;
|
public decimal? Price => PricesUsd;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Alias for Id (Scryfall ID) for compatibility.
|
||||||
|
/// </summary>
|
||||||
|
public string ScryfallId => Id;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Alias for PricesUsd for compatibility.
|
||||||
|
/// </summary>
|
||||||
|
public decimal? PriceUsd => PricesUsd;
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
namespace Scry.Core.Models;
|
|
||||||
|
|
||||||
public record CardHash
|
|
||||||
{
|
|
||||||
public required string CardId { get; init; }
|
|
||||||
public required string Name { get; init; }
|
|
||||||
public required string SetCode { get; init; }
|
|
||||||
public string? CollectorNumber { get; init; }
|
|
||||||
public required byte[] Hash { get; init; }
|
|
||||||
public string? ImageUri { get; init; }
|
|
||||||
}
|
|
||||||
73
src/Scry.Core/Models/Oracle.cs
Normal file
73
src/Scry.Core/Models/Oracle.cs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
namespace Scry.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an abstract game card - the rules object shared across all printings.
|
||||||
|
/// Maps to Scryfall's oracle_id concept.
|
||||||
|
/// </summary>
|
||||||
|
public record Oracle
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scryfall's oracle_id - unique identifier for this game card across all printings.
|
||||||
|
/// </summary>
|
||||||
|
public required string Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The card name (e.g., "Lightning Bolt").
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mana cost in Scryfall notation (e.g., "{2}{U}{U}").
|
||||||
|
/// </summary>
|
||||||
|
public string? ManaCost { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mana value (converted mana cost).
|
||||||
|
/// </summary>
|
||||||
|
public double? Cmc { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full type line (e.g., "Legendary Creature — Human Wizard").
|
||||||
|
/// </summary>
|
||||||
|
public string? TypeLine { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Official Oracle rules text.
|
||||||
|
/// </summary>
|
||||||
|
public string? OracleText { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Card colors as JSON array (e.g., ["U", "R"]).
|
||||||
|
/// </summary>
|
||||||
|
public string? Colors { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Color identity for Commander as JSON array.
|
||||||
|
/// </summary>
|
||||||
|
public string? ColorIdentity { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keywords as JSON array (e.g., ["Flying", "Trample"]).
|
||||||
|
/// </summary>
|
||||||
|
public string? Keywords { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this card is on the Reserved List.
|
||||||
|
/// </summary>
|
||||||
|
public bool Reserved { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format legalities as JSON object.
|
||||||
|
/// </summary>
|
||||||
|
public string? Legalities { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Power for creatures (may contain non-numeric values like "*").
|
||||||
|
/// </summary>
|
||||||
|
public string? Power { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toughness for creatures (may contain non-numeric values like "*").
|
||||||
|
/// </summary>
|
||||||
|
public string? Toughness { get; init; }
|
||||||
|
}
|
||||||
57
src/Scry.Core/Models/Set.cs
Normal file
57
src/Scry.Core/Models/Set.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
namespace Scry.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an MTG set. Maps to Scryfall's Set object.
|
||||||
|
/// </summary>
|
||||||
|
public record Set
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scryfall's unique set ID.
|
||||||
|
/// </summary>
|
||||||
|
public required string Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unique 3-6 letter set code (e.g., "lea", "mh2").
|
||||||
|
/// </summary>
|
||||||
|
public required string Code { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// English name of the set (e.g., "Limited Edition Alpha").
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set classification (e.g., "expansion", "core", "masters", "commander").
|
||||||
|
/// </summary>
|
||||||
|
public string? SetType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Release date in ISO 8601 format.
|
||||||
|
/// </summary>
|
||||||
|
public string? ReleasedAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of cards in the set.
|
||||||
|
/// </summary>
|
||||||
|
public int? CardCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URI to the set's icon SVG.
|
||||||
|
/// </summary>
|
||||||
|
public string? IconSvgUri { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this is a digital-only set.
|
||||||
|
/// </summary>
|
||||||
|
public bool Digital { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parent set code for promo/token sets.
|
||||||
|
/// </summary>
|
||||||
|
public string? ParentSetCode { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Block name, if applicable.
|
||||||
|
/// </summary>
|
||||||
|
public string? Block { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Scry.Core.Data;
|
using Scry.Core.Data;
|
||||||
using Scry.Core.Imaging;
|
using Scry.Core.Imaging;
|
||||||
using Scry.Core.Models;
|
using Scry.Core.Models;
|
||||||
|
|
@ -8,29 +9,23 @@ namespace Scry.Core.Recognition;
|
||||||
|
|
||||||
public class CardRecognitionService : IDisposable
|
public class CardRecognitionService : IDisposable
|
||||||
{
|
{
|
||||||
private readonly CardHashDatabase _database;
|
private readonly CardDatabase _database;
|
||||||
private List<CardHash>? _hashCache;
|
private readonly RecognitionOptions _options;
|
||||||
|
private List<Card>? _cardCache;
|
||||||
private readonly SemaphoreSlim _cacheLock = new(1, 1);
|
private readonly SemaphoreSlim _cacheLock = new(1, 1);
|
||||||
|
|
||||||
private const int ColorHashBits = 192;
|
private const int ColorHashBits = 192;
|
||||||
private const int MatchThreshold = 25;
|
private const int MatchThreshold = 25;
|
||||||
private const float MinConfidence = 0.85f;
|
private const float MinConfidence = 0.85f;
|
||||||
|
|
||||||
/// <summary>
|
public CardRecognitionService(CardDatabase database, IOptions<RecognitionOptions> options)
|
||||||
/// Enable card detection and perspective correction.
|
|
||||||
/// When disabled, assumes the input image is already a cropped card.
|
|
||||||
/// </summary>
|
|
||||||
public bool EnableCardDetection { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Try multiple rotations (0°, 90°, 180°, 270°) when matching.
|
|
||||||
/// Useful when card orientation is unknown.
|
|
||||||
/// </summary>
|
|
||||||
public bool EnableRotationMatching { get; set; } = true;
|
|
||||||
|
|
||||||
public CardRecognitionService(CardHashDatabase database)
|
|
||||||
{
|
{
|
||||||
_database = database;
|
_database = database;
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardRecognitionService(CardDatabase database) : this(database, Options.Create(new RecognitionOptions()))
|
||||||
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ScanResult> RecognizeAsync(Stream imageStream, CancellationToken ct = default)
|
public async Task<ScanResult> RecognizeAsync(Stream imageStream, CancellationToken ct = default)
|
||||||
|
|
@ -60,29 +55,49 @@ public class CardRecognitionService : IDisposable
|
||||||
public async Task<ScanResult> RecognizeAsync(SKBitmap bitmap, CancellationToken ct = default)
|
public async Task<ScanResult> RecognizeAsync(SKBitmap bitmap, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
var debugDir = _options.DebugOutputDirectory;
|
||||||
|
var debugEnabled = !string.IsNullOrEmpty(debugDir);
|
||||||
|
|
||||||
|
if (debugEnabled)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(debugDir!);
|
||||||
|
SaveDebugImage(bitmap, debugDir!, "01_input");
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var hashes = await GetHashCacheAsync(ct);
|
var cards = await GetCardCacheAsync(ct);
|
||||||
Console.WriteLine($"[Scry] Database has {hashes.Count} hashes");
|
Console.WriteLine($"[Scry] Database has {cards.Count} cards with hashes");
|
||||||
|
|
||||||
if (hashes.Count == 0)
|
if (cards.Count == 0)
|
||||||
{
|
{
|
||||||
return ScanResult.Failed("No card hashes in database. Run sync first.");
|
return ScanResult.Failed("No cards in database. Run sync first.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Detect and extract card from image (if enabled)
|
// Step 1: Detect and extract card from image (if enabled)
|
||||||
SKBitmap cardImage;
|
SKBitmap cardImage;
|
||||||
bool cardDetected = false;
|
bool cardDetected = false;
|
||||||
|
|
||||||
if (EnableCardDetection)
|
if (_options.EnableCardDetection)
|
||||||
{
|
{
|
||||||
var detection = CardDetector.DetectCard(bitmap);
|
var detection = CardDetector.DetectCard(bitmap);
|
||||||
|
|
||||||
|
if (debugEnabled)
|
||||||
|
{
|
||||||
|
// Save detection visualization
|
||||||
|
SaveDetectionDebugImage(bitmap, detection, debugDir!);
|
||||||
|
}
|
||||||
|
|
||||||
if (detection.Found)
|
if (detection.Found)
|
||||||
{
|
{
|
||||||
cardImage = PerspectiveCorrection.WarpPerspective(bitmap, detection.Corners);
|
cardImage = PerspectiveCorrection.WarpPerspective(bitmap, detection.Corners);
|
||||||
cardDetected = true;
|
cardDetected = true;
|
||||||
Console.WriteLine($"[Scry] Card detected with confidence {detection.Confidence:P0}");
|
Console.WriteLine($"[Scry] Card detected with confidence {detection.Confidence:P0}");
|
||||||
|
|
||||||
|
if (debugEnabled)
|
||||||
|
{
|
||||||
|
SaveDebugImage(cardImage, debugDir!, "03_perspective_corrected");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -99,9 +114,9 @@ public class CardRecognitionService : IDisposable
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Step 2: Try matching with rotation variants (if enabled)
|
// Step 2: Try matching with rotation variants (if enabled)
|
||||||
var bestMatch = EnableRotationMatching
|
var bestMatch = _options.EnableRotationMatching
|
||||||
? await FindBestMatchWithRotationsAsync(cardImage, hashes, ct)
|
? await FindBestMatchWithRotationsAsync(cardImage, cards, debugDir, ct)
|
||||||
: FindBestMatchSingle(cardImage, hashes);
|
: FindBestMatchSingle(cardImage, cards, debugDir);
|
||||||
|
|
||||||
stopwatch.Stop();
|
stopwatch.Stop();
|
||||||
|
|
||||||
|
|
@ -110,25 +125,16 @@ public class CardRecognitionService : IDisposable
|
||||||
return ScanResult.Failed($"No match found (detection={cardDetected})");
|
return ScanResult.Failed($"No match found (detection={cardDetected})");
|
||||||
}
|
}
|
||||||
|
|
||||||
var (cardHash, distance, rotation) = bestMatch.Value;
|
var (matchedCard, distance, rotation) = bestMatch.Value;
|
||||||
var confidence = PerceptualHash.CalculateConfidence(distance, ColorHashBits);
|
var confidence = PerceptualHash.CalculateConfidence(distance, ColorHashBits);
|
||||||
Console.WriteLine($"[Scry] Best match: {cardHash.Name}, distance={distance}, confidence={confidence:P0}, rotation={rotation}°");
|
Console.WriteLine($"[Scry] Best match: {matchedCard.Name}, distance={distance}, confidence={confidence:P0}, rotation={rotation}°");
|
||||||
|
|
||||||
if (confidence < MinConfidence)
|
if (confidence < MinConfidence)
|
||||||
{
|
{
|
||||||
return ScanResult.Failed($"Match confidence too low: {confidence:P0}");
|
return ScanResult.Failed($"Match confidence too low: {confidence:P0}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var card = new Card
|
return ScanResult.Matched(matchedCard, confidence, distance, stopwatch.Elapsed);
|
||||||
{
|
|
||||||
Id = cardHash.CardId,
|
|
||||||
Name = cardHash.Name,
|
|
||||||
SetCode = cardHash.SetCode,
|
|
||||||
CollectorNumber = cardHash.CollectorNumber,
|
|
||||||
ImageUri = cardHash.ImageUri
|
|
||||||
};
|
|
||||||
|
|
||||||
return ScanResult.Matched(card, confidence, distance, stopwatch.Elapsed);
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -176,7 +182,7 @@ public class CardRecognitionService : IDisposable
|
||||||
await _cacheLock.WaitAsync();
|
await _cacheLock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_hashCache = null;
|
_cardCache = null;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -184,16 +190,16 @@ public class CardRecognitionService : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<CardHash>> GetHashCacheAsync(CancellationToken ct)
|
private async Task<List<Card>> GetCardCacheAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_hashCache != null)
|
if (_cardCache != null)
|
||||||
return _hashCache;
|
return _cardCache;
|
||||||
|
|
||||||
await _cacheLock.WaitAsync(ct);
|
await _cacheLock.WaitAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_hashCache ??= await _database.GetAllHashesAsync(ct);
|
_cardCache ??= await _database.GetCardsWithHashAsync(ct);
|
||||||
return _hashCache;
|
return _cardCache;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -204,16 +210,18 @@ public class CardRecognitionService : IDisposable
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Find best match trying all 4 rotations (0°, 90°, 180°, 270°).
|
/// Find best match trying all 4 rotations (0°, 90°, 180°, 270°).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private Task<(CardHash Hash, int Distance, int Rotation)?> FindBestMatchWithRotationsAsync(
|
private Task<(Card Card, int Distance, int Rotation)?> FindBestMatchWithRotationsAsync(
|
||||||
SKBitmap cardImage,
|
SKBitmap cardImage,
|
||||||
List<CardHash> candidates,
|
List<Card> candidates,
|
||||||
|
string? debugDir,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
return Task.Run(() =>
|
return Task.Run(() =>
|
||||||
{
|
{
|
||||||
CardHash? bestMatch = null;
|
Card? bestMatch = null;
|
||||||
var bestDistance = int.MaxValue;
|
var bestDistance = int.MaxValue;
|
||||||
var bestRotation = 0;
|
var bestRotation = 0;
|
||||||
|
var debugEnabled = !string.IsNullOrEmpty(debugDir);
|
||||||
|
|
||||||
var rotations = new[] { 0, 90, 180, 270 };
|
var rotations = new[] { 0, 90, 180, 270 };
|
||||||
|
|
||||||
|
|
@ -224,14 +232,25 @@ public class CardRecognitionService : IDisposable
|
||||||
using var rotated = rotation == 0 ? null : RotateImage(cardImage, rotation);
|
using var rotated = rotation == 0 ? null : RotateImage(cardImage, rotation);
|
||||||
var imageToHash = rotated ?? cardImage;
|
var imageToHash = rotated ?? cardImage;
|
||||||
|
|
||||||
|
if (debugEnabled && rotation != 0)
|
||||||
|
{
|
||||||
|
SaveDebugImage(imageToHash, debugDir!, $"04_rotated_{rotation}");
|
||||||
|
}
|
||||||
|
|
||||||
// Apply CLAHE and compute hash
|
// Apply CLAHE and compute hash
|
||||||
using var preprocessed = ImagePreprocessor.ApplyClahe(imageToHash);
|
using var preprocessed = ImagePreprocessor.ApplyClahe(imageToHash);
|
||||||
|
|
||||||
|
if (debugEnabled)
|
||||||
|
{
|
||||||
|
SaveDebugImage(preprocessed, debugDir!, $"05_clahe_{rotation}");
|
||||||
|
}
|
||||||
|
|
||||||
var queryHash = PerceptualHash.ComputeColorHash(preprocessed);
|
var queryHash = PerceptualHash.ComputeColorHash(preprocessed);
|
||||||
|
|
||||||
// Find best match for this rotation
|
// Find best match for this rotation
|
||||||
foreach (var candidate in candidates)
|
foreach (var candidate in candidates)
|
||||||
{
|
{
|
||||||
if (candidate.Hash.Length != queryHash.Length)
|
if (candidate.Hash == null || candidate.Hash.Length != queryHash.Length)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var distance = PerceptualHash.HammingDistance(queryHash, candidate.Hash);
|
var distance = PerceptualHash.HammingDistance(queryHash, candidate.Hash);
|
||||||
|
|
@ -252,27 +271,36 @@ public class CardRecognitionService : IDisposable
|
||||||
if (bestMatch == null || bestDistance > MatchThreshold)
|
if (bestMatch == null || bestDistance > MatchThreshold)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return ((CardHash Hash, int Distance, int Rotation)?)(bestMatch, bestDistance, bestRotation);
|
return ((Card Card, int Distance, int Rotation)?)(bestMatch, bestDistance, bestRotation);
|
||||||
}, ct);
|
}, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Find best match without rotation (single orientation).
|
/// Find best match without rotation (single orientation).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private (CardHash Hash, int Distance, int Rotation)? FindBestMatchSingle(
|
private (Card Card, int Distance, int Rotation)? FindBestMatchSingle(
|
||||||
SKBitmap cardImage,
|
SKBitmap cardImage,
|
||||||
List<CardHash> candidates)
|
List<Card> candidates,
|
||||||
|
string? debugDir)
|
||||||
{
|
{
|
||||||
|
var debugEnabled = !string.IsNullOrEmpty(debugDir);
|
||||||
|
|
||||||
// Apply CLAHE and compute hash
|
// Apply CLAHE and compute hash
|
||||||
using var preprocessed = ImagePreprocessor.ApplyClahe(cardImage);
|
using var preprocessed = ImagePreprocessor.ApplyClahe(cardImage);
|
||||||
|
|
||||||
|
if (debugEnabled)
|
||||||
|
{
|
||||||
|
SaveDebugImage(preprocessed, debugDir!, "05_clahe_0");
|
||||||
|
}
|
||||||
|
|
||||||
var queryHash = PerceptualHash.ComputeColorHash(preprocessed);
|
var queryHash = PerceptualHash.ComputeColorHash(preprocessed);
|
||||||
|
|
||||||
CardHash? bestMatch = null;
|
Card? bestMatch = null;
|
||||||
var bestDistance = int.MaxValue;
|
var bestDistance = int.MaxValue;
|
||||||
|
|
||||||
foreach (var candidate in candidates)
|
foreach (var candidate in candidates)
|
||||||
{
|
{
|
||||||
if (candidate.Hash.Length != queryHash.Length)
|
if (candidate.Hash == null || candidate.Hash.Length != queryHash.Length)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var distance = PerceptualHash.HammingDistance(queryHash, candidate.Hash);
|
var distance = PerceptualHash.HammingDistance(queryHash, candidate.Hash);
|
||||||
|
|
@ -317,6 +345,83 @@ public class CardRecognitionService : IDisposable
|
||||||
return rotated;
|
return rotated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save a debug image to disk.
|
||||||
|
/// </summary>
|
||||||
|
private static void SaveDebugImage(SKBitmap bitmap, string directory, string name)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(directory, $"{name}.png");
|
||||||
|
using var image = SKImage.FromBitmap(bitmap);
|
||||||
|
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
|
||||||
|
using var stream = File.OpenWrite(path);
|
||||||
|
data.SaveTo(stream);
|
||||||
|
Console.WriteLine($"[Scry Debug] Saved: {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save a debug image showing the card detection result.
|
||||||
|
/// </summary>
|
||||||
|
private static void SaveDetectionDebugImage(SKBitmap original, CardDetector.CardDetectionResult detection, string directory)
|
||||||
|
{
|
||||||
|
using var annotated = new SKBitmap(original.Width, original.Height, original.ColorType, original.AlphaType);
|
||||||
|
using var canvas = new SKCanvas(annotated);
|
||||||
|
|
||||||
|
canvas.DrawBitmap(original, 0, 0);
|
||||||
|
|
||||||
|
if (detection.Found && detection.Corners.Length == 4)
|
||||||
|
{
|
||||||
|
// Draw detected corners and edges
|
||||||
|
using var cornerPaint = new SKPaint
|
||||||
|
{
|
||||||
|
Color = SKColors.Red,
|
||||||
|
Style = SKPaintStyle.Fill,
|
||||||
|
IsAntialias = true
|
||||||
|
};
|
||||||
|
|
||||||
|
using var edgePaint = new SKPaint
|
||||||
|
{
|
||||||
|
Color = SKColors.Lime,
|
||||||
|
Style = SKPaintStyle.Stroke,
|
||||||
|
StrokeWidth = 3,
|
||||||
|
IsAntialias = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var corners = detection.Corners;
|
||||||
|
|
||||||
|
// Draw edges
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
var p1 = corners[i];
|
||||||
|
var p2 = corners[(i + 1) % 4];
|
||||||
|
canvas.DrawLine(p1.X, p1.Y, p2.X, p2.Y, edgePaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw corners
|
||||||
|
foreach (var corner in corners)
|
||||||
|
{
|
||||||
|
canvas.DrawCircle(corner.X, corner.Y, 8, cornerPaint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add debug text
|
||||||
|
using var textPaint = new SKPaint
|
||||||
|
{
|
||||||
|
Color = detection.Found ? SKColors.Lime : SKColors.Red,
|
||||||
|
IsAntialias = true
|
||||||
|
};
|
||||||
|
using var font = new SKFont
|
||||||
|
{
|
||||||
|
Size = 24
|
||||||
|
};
|
||||||
|
|
||||||
|
var message = detection.Found
|
||||||
|
? $"Detected: {detection.Confidence:P0}"
|
||||||
|
: $"Not found: {detection.DebugMessage}";
|
||||||
|
canvas.DrawText(message, 10, 30, SKTextAlign.Left, font, textPaint);
|
||||||
|
|
||||||
|
SaveDebugImage(annotated, directory, "02_detection");
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_cacheLock.Dispose();
|
_cacheLock.Dispose();
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ namespace Scry.Core.Recognition;
|
||||||
public class HashDatabaseSyncService
|
public class HashDatabaseSyncService
|
||||||
{
|
{
|
||||||
private readonly ScryfallClient _scryfallClient;
|
private readonly ScryfallClient _scryfallClient;
|
||||||
private readonly CardHashDatabase _database;
|
private readonly CardDatabase _database;
|
||||||
private readonly HttpClient _imageClient;
|
private readonly HttpClient _imageClient;
|
||||||
|
|
||||||
public event Action<SyncProgress>? OnProgress;
|
public event Action<SyncProgress>? OnProgress;
|
||||||
|
|
||||||
public HashDatabaseSyncService(ScryfallClient scryfallClient, CardHashDatabase database, HttpClient? imageClient = null)
|
public HashDatabaseSyncService(ScryfallClient scryfallClient, CardDatabase database, HttpClient? imageClient = null)
|
||||||
{
|
{
|
||||||
_scryfallClient = scryfallClient;
|
_scryfallClient = scryfallClient;
|
||||||
_database = database;
|
_database = database;
|
||||||
|
|
@ -29,6 +29,21 @@ public class HashDatabaseSyncService
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Fetch all sets first
|
||||||
|
ReportProgress(new SyncProgress { Stage = SyncStage.Initializing, Message = "Fetching sets..." });
|
||||||
|
var scryfallSets = await _scryfallClient.GetAllSetsAsync(ct);
|
||||||
|
var existingSetIds = await _database.GetExistingSetIdsAsync(ct);
|
||||||
|
|
||||||
|
var newSets = scryfallSets
|
||||||
|
.Where(s => s.Id != null && !existingSetIds.Contains(s.Id))
|
||||||
|
.Select(s => s.ToSet())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (newSets.Count > 0)
|
||||||
|
{
|
||||||
|
await _database.InsertSetBatchAsync(newSets, ct);
|
||||||
|
}
|
||||||
|
|
||||||
var bulkInfo = await _scryfallClient.GetBulkDataInfoAsync(options.BulkDataType, ct);
|
var bulkInfo = await _scryfallClient.GetBulkDataInfoAsync(options.BulkDataType, ct);
|
||||||
if (bulkInfo?.DownloadUri == null)
|
if (bulkInfo?.DownloadUri == null)
|
||||||
{
|
{
|
||||||
|
|
@ -50,21 +65,25 @@ public class HashDatabaseSyncService
|
||||||
|
|
||||||
ReportProgress(new SyncProgress { Stage = SyncStage.Downloading, Message = "Downloading card data..." });
|
ReportProgress(new SyncProgress { Stage = SyncStage.Downloading, Message = "Downloading card data..." });
|
||||||
|
|
||||||
var batch = new List<CardHash>();
|
var existingOracleIds = await _database.GetExistingOracleIdsAsync(ct);
|
||||||
|
var cardBatch = new List<Card>();
|
||||||
|
var oracleBatch = new Dictionary<string, Oracle>();
|
||||||
var processed = 0;
|
var processed = 0;
|
||||||
var errors = 0;
|
var errors = 0;
|
||||||
|
|
||||||
await foreach (var card in _scryfallClient.StreamBulkDataAsync(bulkInfo.DownloadUri, ct))
|
await foreach (var scryfallCard in _scryfallClient.StreamBulkDataAsync(bulkInfo.DownloadUri, ct))
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
if (card.Lang != "en" && !options.IncludeNonEnglish)
|
if (scryfallCard.Lang != "en" && !options.IncludeNonEnglish)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var imageUri = card.GetImageUri(options.ImageSize);
|
var imageUri = scryfallCard.GetImageUri(options.ImageSize);
|
||||||
if (string.IsNullOrEmpty(imageUri))
|
if (string.IsNullOrEmpty(imageUri))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
var oracleId = scryfallCard.OracleId ?? scryfallCard.Id ?? "";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var imageBytes = await DownloadWithRetryAsync(imageUri, options.MaxRetries, ct);
|
var imageBytes = await DownloadWithRetryAsync(imageUri, options.MaxRetries, ct);
|
||||||
|
|
@ -84,23 +103,34 @@ public class HashDatabaseSyncService
|
||||||
using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
|
using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
|
||||||
var hash = PerceptualHash.ComputeColorHash(preprocessed);
|
var hash = PerceptualHash.ComputeColorHash(preprocessed);
|
||||||
|
|
||||||
batch.Add(new CardHash
|
// Track oracle if new
|
||||||
|
if (!existingOracleIds.Contains(oracleId) && !oracleBatch.ContainsKey(oracleId))
|
||||||
{
|
{
|
||||||
CardId = card.Id ?? Guid.NewGuid().ToString(),
|
oracleBatch[oracleId] = scryfallCard.ToOracle();
|
||||||
Name = card.Name ?? "Unknown",
|
}
|
||||||
SetCode = card.Set ?? "???",
|
|
||||||
CollectorNumber = card.CollectorNumber,
|
// Create card with hash
|
||||||
Hash = hash,
|
var card = scryfallCard.ToCard() with { Hash = hash };
|
||||||
ImageUri = imageUri
|
cardBatch.Add(card);
|
||||||
});
|
|
||||||
|
|
||||||
processed++;
|
processed++;
|
||||||
|
|
||||||
if (batch.Count >= options.BatchSize)
|
if (cardBatch.Count >= options.BatchSize)
|
||||||
{
|
{
|
||||||
await _database.InsertHashBatchAsync(batch, ct);
|
// Insert oracles first
|
||||||
result.ProcessedCards += batch.Count;
|
if (oracleBatch.Count > 0)
|
||||||
batch.Clear();
|
{
|
||||||
|
await _database.InsertOracleBatchAsync(oracleBatch.Values, ct);
|
||||||
|
foreach (var id in oracleBatch.Keys)
|
||||||
|
{
|
||||||
|
existingOracleIds.Add(id);
|
||||||
|
}
|
||||||
|
oracleBatch.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _database.InsertCardBatchAsync(cardBatch, ct);
|
||||||
|
result.ProcessedCards += cardBatch.Count;
|
||||||
|
cardBatch.Clear();
|
||||||
|
|
||||||
ReportProgress(new SyncProgress
|
ReportProgress(new SyncProgress
|
||||||
{
|
{
|
||||||
|
|
@ -121,14 +151,20 @@ public class HashDatabaseSyncService
|
||||||
if (options.StopOnError)
|
if (options.StopOnError)
|
||||||
throw;
|
throw;
|
||||||
|
|
||||||
result.Errors.Add($"{card.Name}: {ex.Message}");
|
result.Errors.Add($"{scryfallCard.Name}: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (batch.Count > 0)
|
// Insert remaining batches
|
||||||
|
if (oracleBatch.Count > 0)
|
||||||
{
|
{
|
||||||
await _database.InsertHashBatchAsync(batch, ct);
|
await _database.InsertOracleBatchAsync(oracleBatch.Values, ct);
|
||||||
result.ProcessedCards += batch.Count;
|
}
|
||||||
|
|
||||||
|
if (cardBatch.Count > 0)
|
||||||
|
{
|
||||||
|
await _database.InsertCardBatchAsync(cardBatch, ct);
|
||||||
|
result.ProcessedCards += cardBatch.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _database.SetMetadataAsync("last_sync_date", DateTime.UtcNow.ToString("O"), ct);
|
await _database.SetMetadataAsync("last_sync_date", DateTime.UtcNow.ToString("O"), ct);
|
||||||
|
|
|
||||||
24
src/Scry.Core/Recognition/RecognitionOptions.cs
Normal file
24
src/Scry.Core/Recognition/RecognitionOptions.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
namespace Scry.Core.Recognition;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration options for card recognition.
|
||||||
|
/// </summary>
|
||||||
|
public class RecognitionOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// When set, saves debug images of each pipeline step to this directory.
|
||||||
|
/// </summary>
|
||||||
|
public string? DebugOutputDirectory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enable card detection and perspective correction.
|
||||||
|
/// When disabled, assumes the input image is already a cropped card.
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableCardDetection { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Try multiple rotations (0°, 90°, 180°, 270°) when matching.
|
||||||
|
/// Useful when card orientation is unknown.
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableRotationMatching { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="SkiaSharp" />
|
<PackageReference Include="SkiaSharp" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Scry.Core.Models;
|
using Scry.Core.Models;
|
||||||
|
|
||||||
namespace Scry.Core.Scryfall;
|
namespace Scry.Core.Scryfall;
|
||||||
|
|
@ -8,6 +9,7 @@ public class ScryfallClient : IDisposable
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private const string BulkDataUrl = "https://api.scryfall.com/bulk-data";
|
private const string BulkDataUrl = "https://api.scryfall.com/bulk-data";
|
||||||
|
private const string SetsUrl = "https://api.scryfall.com/sets";
|
||||||
|
|
||||||
public ScryfallClient(HttpClient? httpClient = null)
|
public ScryfallClient(HttpClient? httpClient = null)
|
||||||
{
|
{
|
||||||
|
|
@ -24,6 +26,27 @@ public class ScryfallClient : IDisposable
|
||||||
d.Type?.Equals(type, StringComparison.OrdinalIgnoreCase) == true);
|
d.Type?.Equals(type, StringComparison.OrdinalIgnoreCase) == true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<ScryfallSet>> GetAllSetsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sets = new List<ScryfallSet>();
|
||||||
|
var url = SetsUrl;
|
||||||
|
|
||||||
|
while (!string.IsNullOrEmpty(url))
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetStringAsync(url, ct);
|
||||||
|
var setsResponse = JsonSerializer.Deserialize<SetsResponse>(response, JsonOptions);
|
||||||
|
|
||||||
|
if (setsResponse?.Data != null)
|
||||||
|
{
|
||||||
|
sets.AddRange(setsResponse.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
url = setsResponse?.NextPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sets;
|
||||||
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<ScryfallCard> StreamBulkDataAsync(
|
public async IAsyncEnumerable<ScryfallCard> StreamBulkDataAsync(
|
||||||
string downloadUri,
|
string downloadUri,
|
||||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
|
||||||
|
|
@ -72,6 +95,8 @@ public class ScryfallClient : IDisposable
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region API Response Models
|
||||||
|
|
||||||
public record BulkDataResponse
|
public record BulkDataResponse
|
||||||
{
|
{
|
||||||
public List<BulkDataInfo>? Data { get; init; }
|
public List<BulkDataInfo>? Data { get; init; }
|
||||||
|
|
@ -87,21 +112,62 @@ public record BulkDataInfo
|
||||||
public long? Size { get; init; }
|
public long? Size { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ScryfallCard
|
public record SetsResponse
|
||||||
|
{
|
||||||
|
public List<ScryfallSet>? Data { get; init; }
|
||||||
|
public bool HasMore { get; init; }
|
||||||
|
public string? NextPage { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ScryfallSet
|
||||||
{
|
{
|
||||||
public string? Id { get; init; }
|
public string? Id { get; init; }
|
||||||
|
public string? Code { get; init; }
|
||||||
public string? Name { get; init; }
|
public string? Name { get; init; }
|
||||||
|
public string? SetType { get; init; }
|
||||||
|
public string? ReleasedAt { get; init; }
|
||||||
|
public int? CardCount { get; init; }
|
||||||
|
public string? IconSvgUri { get; init; }
|
||||||
|
public bool Digital { get; init; }
|
||||||
|
public string? ParentSetCode { get; init; }
|
||||||
|
public string? Block { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ScryfallCard
|
||||||
|
{
|
||||||
|
// Core identifiers
|
||||||
|
public string? Id { get; init; }
|
||||||
|
public string? OracleId { get; init; }
|
||||||
|
|
||||||
|
// Oracle/game card fields
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public string? ManaCost { get; init; }
|
||||||
|
public double? Cmc { get; init; }
|
||||||
|
public string? TypeLine { get; init; }
|
||||||
|
public string? OracleText { get; init; }
|
||||||
|
public List<string>? Colors { get; init; }
|
||||||
|
public List<string>? ColorIdentity { get; init; }
|
||||||
|
public List<string>? Keywords { get; init; }
|
||||||
|
public bool Reserved { get; init; }
|
||||||
|
public Dictionary<string, string>? Legalities { get; init; }
|
||||||
|
public string? Power { get; init; }
|
||||||
|
public string? Toughness { get; init; }
|
||||||
|
|
||||||
|
// Printing-specific fields
|
||||||
public string? Set { get; init; }
|
public string? Set { get; init; }
|
||||||
|
public string? SetId { get; init; }
|
||||||
public string? SetName { get; init; }
|
public string? SetName { get; init; }
|
||||||
public string? CollectorNumber { get; init; }
|
public string? CollectorNumber { get; init; }
|
||||||
public string? Rarity { get; init; }
|
public string? Rarity { get; init; }
|
||||||
public string? ManaCost { get; init; }
|
|
||||||
public string? TypeLine { get; init; }
|
|
||||||
public string? OracleText { get; init; }
|
|
||||||
public string? Lang { get; init; }
|
|
||||||
public string? Artist { get; init; }
|
public string? Artist { get; init; }
|
||||||
|
public string? IllustrationId { get; init; }
|
||||||
|
public string? Lang { get; init; }
|
||||||
|
|
||||||
|
// Images and prices
|
||||||
public ImageUris? ImageUris { get; init; }
|
public ImageUris? ImageUris { get; init; }
|
||||||
public Prices? Prices { get; init; }
|
public Prices? Prices { get; init; }
|
||||||
|
|
||||||
|
// Multi-face cards
|
||||||
public List<CardFace>? CardFaces { get; init; }
|
public List<CardFace>? CardFaces { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,11 +194,45 @@ public record CardFace
|
||||||
public string? ManaCost { get; init; }
|
public string? ManaCost { get; init; }
|
||||||
public string? TypeLine { get; init; }
|
public string? TypeLine { get; init; }
|
||||||
public string? OracleText { get; init; }
|
public string? OracleText { get; init; }
|
||||||
|
public List<string>? Colors { get; init; }
|
||||||
|
public string? Power { get; init; }
|
||||||
|
public string? Toughness { get; init; }
|
||||||
public ImageUris? ImageUris { get; init; }
|
public ImageUris? ImageUris { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Extension Methods
|
||||||
|
|
||||||
public static class ScryfallCardExtensions
|
public static class ScryfallCardExtensions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the Oracle (abstract game card) from a Scryfall card.
|
||||||
|
/// </summary>
|
||||||
|
public static Oracle ToOracle(this ScryfallCard scryfall)
|
||||||
|
{
|
||||||
|
return new Oracle
|
||||||
|
{
|
||||||
|
Id = scryfall.OracleId ?? scryfall.Id ?? Guid.NewGuid().ToString(),
|
||||||
|
Name = scryfall.Name ?? "Unknown",
|
||||||
|
ManaCost = scryfall.ManaCost,
|
||||||
|
Cmc = scryfall.Cmc,
|
||||||
|
TypeLine = scryfall.TypeLine,
|
||||||
|
OracleText = scryfall.OracleText,
|
||||||
|
Colors = scryfall.Colors != null ? JsonSerializer.Serialize(scryfall.Colors) : null,
|
||||||
|
ColorIdentity = scryfall.ColorIdentity != null ? JsonSerializer.Serialize(scryfall.ColorIdentity) : null,
|
||||||
|
Keywords = scryfall.Keywords != null ? JsonSerializer.Serialize(scryfall.Keywords) : null,
|
||||||
|
Reserved = scryfall.Reserved,
|
||||||
|
Legalities = scryfall.Legalities != null ? JsonSerializer.Serialize(scryfall.Legalities) : null,
|
||||||
|
Power = scryfall.Power,
|
||||||
|
Toughness = scryfall.Toughness,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a Scryfall card to a Card (printing) model.
|
||||||
|
/// Note: Hash must be computed separately and set on the returned Card.
|
||||||
|
/// </summary>
|
||||||
public static Card ToCard(this ScryfallCard scryfall)
|
public static Card ToCard(this ScryfallCard scryfall)
|
||||||
{
|
{
|
||||||
var imageUris = scryfall.ImageUris ?? scryfall.CardFaces?.FirstOrDefault()?.ImageUris;
|
var imageUris = scryfall.ImageUris ?? scryfall.CardFaces?.FirstOrDefault()?.ImageUris;
|
||||||
|
|
@ -140,21 +240,46 @@ public static class ScryfallCardExtensions
|
||||||
return new Card
|
return new Card
|
||||||
{
|
{
|
||||||
Id = scryfall.Id ?? Guid.NewGuid().ToString(),
|
Id = scryfall.Id ?? Guid.NewGuid().ToString(),
|
||||||
Name = scryfall.Name ?? "Unknown",
|
OracleId = scryfall.OracleId ?? scryfall.Id ?? Guid.NewGuid().ToString(),
|
||||||
|
SetId = scryfall.SetId ?? "",
|
||||||
SetCode = scryfall.Set,
|
SetCode = scryfall.Set,
|
||||||
SetName = scryfall.SetName,
|
SetName = scryfall.SetName,
|
||||||
|
Name = scryfall.Name ?? "Unknown",
|
||||||
CollectorNumber = scryfall.CollectorNumber,
|
CollectorNumber = scryfall.CollectorNumber,
|
||||||
Rarity = scryfall.Rarity,
|
Rarity = scryfall.Rarity,
|
||||||
|
Artist = scryfall.Artist,
|
||||||
|
IllustrationId = scryfall.IllustrationId,
|
||||||
|
ImageUri = imageUris?.Normal,
|
||||||
|
Lang = scryfall.Lang,
|
||||||
|
PricesUsd = decimal.TryParse(scryfall.Prices?.Usd, out var usd) ? usd : null,
|
||||||
|
PricesUsdFoil = decimal.TryParse(scryfall.Prices?.UsdFoil, out var foil) ? foil : null,
|
||||||
|
Hash = null, // Must be computed separately
|
||||||
|
// Denormalized Oracle fields
|
||||||
ManaCost = scryfall.ManaCost,
|
ManaCost = scryfall.ManaCost,
|
||||||
TypeLine = scryfall.TypeLine,
|
TypeLine = scryfall.TypeLine,
|
||||||
OracleText = scryfall.OracleText,
|
OracleText = scryfall.OracleText,
|
||||||
ImageUri = imageUris?.Normal,
|
Power = scryfall.Power,
|
||||||
ImageUriSmall = imageUris?.Small,
|
Toughness = scryfall.Toughness,
|
||||||
ImageUriLarge = imageUris?.Large ?? imageUris?.Png,
|
};
|
||||||
Artist = scryfall.Artist,
|
}
|
||||||
Lang = scryfall.Lang,
|
|
||||||
PriceUsd = decimal.TryParse(scryfall.Prices?.Usd, out var usd) ? usd : null,
|
/// <summary>
|
||||||
PriceUsdFoil = decimal.TryParse(scryfall.Prices?.UsdFoil, out var foil) ? foil : null
|
/// Converts a Scryfall set to a Set model.
|
||||||
|
/// </summary>
|
||||||
|
public static Set ToSet(this ScryfallSet scryfall)
|
||||||
|
{
|
||||||
|
return new Set
|
||||||
|
{
|
||||||
|
Id = scryfall.Id ?? Guid.NewGuid().ToString(),
|
||||||
|
Code = scryfall.Code ?? "???",
|
||||||
|
Name = scryfall.Name ?? "Unknown",
|
||||||
|
SetType = scryfall.SetType,
|
||||||
|
ReleasedAt = scryfall.ReleasedAt,
|
||||||
|
CardCount = scryfall.CardCount,
|
||||||
|
IconSvgUri = scryfall.IconSvgUri,
|
||||||
|
Digital = scryfall.Digital,
|
||||||
|
ParentSetCode = scryfall.ParentSetCode,
|
||||||
|
Block = scryfall.Block,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,3 +298,5 @@ public static class ScryfallCardExtensions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
|
||||||
304
test/Scry.Tests/CardDatabaseTests.cs
Normal file
304
test/Scry.Tests/CardDatabaseTests.cs
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Scry.Core.Data;
|
||||||
|
using Scry.Core.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Scry.Tests;
|
||||||
|
|
||||||
|
public class CardDatabaseTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
private readonly CardDatabase _database;
|
||||||
|
|
||||||
|
public CardDatabaseTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"scry_test_{Guid.NewGuid()}.db");
|
||||||
|
_database = new CardDatabase(_dbPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsertCard_ThenRetrieve_ReturnsMatch()
|
||||||
|
{
|
||||||
|
// First insert oracle and set (foreign keys)
|
||||||
|
var oracle = new Oracle
|
||||||
|
{
|
||||||
|
Id = "oracle-1",
|
||||||
|
Name = "Test Card",
|
||||||
|
ManaCost = "{1}{U}",
|
||||||
|
TypeLine = "Creature"
|
||||||
|
};
|
||||||
|
await _database.InsertOracleAsync(oracle);
|
||||||
|
|
||||||
|
var set = new Set
|
||||||
|
{
|
||||||
|
Id = "set-1",
|
||||||
|
Code = "TST",
|
||||||
|
Name = "Test Set"
|
||||||
|
};
|
||||||
|
await _database.InsertSetAsync(set);
|
||||||
|
|
||||||
|
var card = new Card
|
||||||
|
{
|
||||||
|
Id = "test-id",
|
||||||
|
OracleId = "oracle-1",
|
||||||
|
SetId = "set-1",
|
||||||
|
SetCode = "TST",
|
||||||
|
Name = "Test Card",
|
||||||
|
CollectorNumber = "1",
|
||||||
|
Hash = new byte[] { 0x01, 0x02, 0x03 },
|
||||||
|
ImageUri = "https://example.com/image.jpg"
|
||||||
|
};
|
||||||
|
|
||||||
|
await _database.InsertCardAsync(card);
|
||||||
|
var retrieved = await _database.GetCardByIdAsync("test-id");
|
||||||
|
|
||||||
|
Assert.NotNull(retrieved);
|
||||||
|
Assert.Equal("Test Card", retrieved.Name);
|
||||||
|
Assert.Equal("TST", retrieved.SetCode);
|
||||||
|
Assert.Equal(card.Hash, retrieved.Hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsertCardBatch_InsertsAllCards()
|
||||||
|
{
|
||||||
|
// Insert oracle first
|
||||||
|
var oracle = new Oracle { Id = "oracle-batch", Name = "Batch Card" };
|
||||||
|
await _database.InsertOracleAsync(oracle);
|
||||||
|
|
||||||
|
var set = new Set { Id = "set-batch", Code = "TST", Name = "Test Set" };
|
||||||
|
await _database.InsertSetAsync(set);
|
||||||
|
|
||||||
|
var cards = Enumerable.Range(0, 100).Select(i => new Card
|
||||||
|
{
|
||||||
|
Id = $"card-{i}",
|
||||||
|
OracleId = "oracle-batch",
|
||||||
|
SetId = "set-batch",
|
||||||
|
SetCode = "TST",
|
||||||
|
Name = $"Card {i}",
|
||||||
|
Hash = new byte[] { (byte)i }
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
await _database.InsertCardBatchAsync(cards);
|
||||||
|
var count = await _database.GetCardCountAsync();
|
||||||
|
|
||||||
|
Assert.Equal(100, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllCards_ReturnsAllCards()
|
||||||
|
{
|
||||||
|
var oracle = new Oracle { Id = "oracle-all", Name = "All Card" };
|
||||||
|
await _database.InsertOracleAsync(oracle);
|
||||||
|
|
||||||
|
var set = new Set { Id = "set-all", Code = "TST", Name = "Test Set" };
|
||||||
|
await _database.InsertSetAsync(set);
|
||||||
|
|
||||||
|
var cards = Enumerable.Range(0, 10).Select(i => new Card
|
||||||
|
{
|
||||||
|
Id = $"card-{i}",
|
||||||
|
OracleId = "oracle-all",
|
||||||
|
SetId = "set-all",
|
||||||
|
SetCode = "TST",
|
||||||
|
Name = $"Card {i}",
|
||||||
|
Hash = new byte[] { (byte)i }
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
await _database.InsertCardBatchAsync(cards);
|
||||||
|
var all = await _database.GetAllCardsAsync();
|
||||||
|
|
||||||
|
Assert.Equal(10, all.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCardsByOracleId_ReturnsAllPrintings()
|
||||||
|
{
|
||||||
|
var oracle = new Oracle { Id = "oracle-multi", Name = "Multi Print Card" };
|
||||||
|
await _database.InsertOracleAsync(oracle);
|
||||||
|
|
||||||
|
var set1 = new Set { Id = "set-1", Code = "S1", Name = "Set 1" };
|
||||||
|
var set2 = new Set { Id = "set-2", Code = "S2", Name = "Set 2" };
|
||||||
|
await _database.InsertSetAsync(set1);
|
||||||
|
await _database.InsertSetAsync(set2);
|
||||||
|
|
||||||
|
var cards = new[]
|
||||||
|
{
|
||||||
|
new Card { Id = "print-1", OracleId = "oracle-multi", SetId = "set-1", SetCode = "S1", Name = "Multi Print Card", Hash = new byte[] { 0x01 } },
|
||||||
|
new Card { Id = "print-2", OracleId = "oracle-multi", SetId = "set-2", SetCode = "S2", Name = "Multi Print Card", Hash = new byte[] { 0x02 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
await _database.InsertCardBatchAsync(cards);
|
||||||
|
var printings = await _database.GetCardsByOracleIdAsync("oracle-multi");
|
||||||
|
|
||||||
|
Assert.Equal(2, printings.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Metadata_SetAndGet()
|
||||||
|
{
|
||||||
|
await _database.SetMetadataAsync("test_key", "test_value");
|
||||||
|
var value = await _database.GetMetadataAsync("test_key");
|
||||||
|
|
||||||
|
Assert.Equal("test_value", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClearCards_RemovesAllCards()
|
||||||
|
{
|
||||||
|
var oracle = new Oracle { Id = "oracle-clear", Name = "Clear Card" };
|
||||||
|
await _database.InsertOracleAsync(oracle);
|
||||||
|
|
||||||
|
var set = new Set { Id = "set-clear", Code = "TST", Name = "Test Set" };
|
||||||
|
await _database.InsertSetAsync(set);
|
||||||
|
|
||||||
|
var cards = Enumerable.Range(0, 10).Select(i => new Card
|
||||||
|
{
|
||||||
|
Id = $"card-{i}",
|
||||||
|
OracleId = "oracle-clear",
|
||||||
|
SetId = "set-clear",
|
||||||
|
SetCode = "TST",
|
||||||
|
Name = $"Card {i}",
|
||||||
|
Hash = new byte[] { (byte)i }
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
await _database.InsertCardBatchAsync(cards);
|
||||||
|
await _database.ClearCardsAsync();
|
||||||
|
var count = await _database.GetCardCountAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsertCard_DuplicateId_Updates()
|
||||||
|
{
|
||||||
|
var oracle = new Oracle { Id = "oracle-dup", Name = "Dup Card" };
|
||||||
|
await _database.InsertOracleAsync(oracle);
|
||||||
|
|
||||||
|
var set = new Set { Id = "set-dup", Code = "TST", Name = "Test Set" };
|
||||||
|
await _database.InsertSetAsync(set);
|
||||||
|
|
||||||
|
var card1 = new Card
|
||||||
|
{
|
||||||
|
Id = "duplicate-id",
|
||||||
|
OracleId = "oracle-dup",
|
||||||
|
SetId = "set-dup",
|
||||||
|
SetCode = "TST",
|
||||||
|
Name = "Original Name",
|
||||||
|
Hash = new byte[] { 0x01 }
|
||||||
|
};
|
||||||
|
|
||||||
|
var card2 = new Card
|
||||||
|
{
|
||||||
|
Id = "duplicate-id",
|
||||||
|
OracleId = "oracle-dup",
|
||||||
|
SetId = "set-dup",
|
||||||
|
SetCode = "TST",
|
||||||
|
Name = "Updated Name",
|
||||||
|
Hash = new byte[] { 0x02 }
|
||||||
|
};
|
||||||
|
|
||||||
|
await _database.InsertCardAsync(card1);
|
||||||
|
await _database.InsertCardAsync(card2);
|
||||||
|
|
||||||
|
var retrieved = await _database.GetCardByIdAsync("duplicate-id");
|
||||||
|
|
||||||
|
Assert.NotNull(retrieved);
|
||||||
|
Assert.Equal("Updated Name", retrieved.Name);
|
||||||
|
Assert.Equal(new byte[] { 0x02 }, retrieved.Hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsertOracle_ThenRetrieveByName()
|
||||||
|
{
|
||||||
|
var oracle = new Oracle
|
||||||
|
{
|
||||||
|
Id = "oracle-name",
|
||||||
|
Name = "Lightning Bolt",
|
||||||
|
ManaCost = "{R}",
|
||||||
|
Cmc = 1,
|
||||||
|
TypeLine = "Instant",
|
||||||
|
OracleText = "Lightning Bolt deals 3 damage to any target."
|
||||||
|
};
|
||||||
|
|
||||||
|
await _database.InsertOracleAsync(oracle);
|
||||||
|
var retrieved = await _database.GetOracleByNameAsync("Lightning Bolt");
|
||||||
|
|
||||||
|
Assert.NotNull(retrieved);
|
||||||
|
Assert.Equal("{R}", retrieved.ManaCost);
|
||||||
|
Assert.Equal(1, retrieved.Cmc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsertSet_ThenRetrieveByCode()
|
||||||
|
{
|
||||||
|
var set = new Set
|
||||||
|
{
|
||||||
|
Id = "set-lea",
|
||||||
|
Code = "lea",
|
||||||
|
Name = "Limited Edition Alpha",
|
||||||
|
SetType = "expansion",
|
||||||
|
ReleasedAt = "1993-08-05",
|
||||||
|
CardCount = 295
|
||||||
|
};
|
||||||
|
|
||||||
|
await _database.InsertSetAsync(set);
|
||||||
|
var retrieved = await _database.GetSetByCodeAsync("lea");
|
||||||
|
|
||||||
|
Assert.NotNull(retrieved);
|
||||||
|
Assert.Equal("Limited Edition Alpha", retrieved.Name);
|
||||||
|
Assert.Equal(295, retrieved.CardCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCardsWithHash_OnlyReturnsCardsWithHash()
|
||||||
|
{
|
||||||
|
var oracle = new Oracle { Id = "oracle-hash", Name = "Hash Card" };
|
||||||
|
await _database.InsertOracleAsync(oracle);
|
||||||
|
|
||||||
|
var set = new Set { Id = "set-hash", Code = "TST", Name = "Test Set" };
|
||||||
|
await _database.InsertSetAsync(set);
|
||||||
|
|
||||||
|
var cardWithHash = new Card
|
||||||
|
{
|
||||||
|
Id = "card-with-hash",
|
||||||
|
OracleId = "oracle-hash",
|
||||||
|
SetId = "set-hash",
|
||||||
|
SetCode = "TST",
|
||||||
|
Name = "Has Hash",
|
||||||
|
Hash = new byte[] { 0x01 }
|
||||||
|
};
|
||||||
|
|
||||||
|
var cardWithoutHash = new Card
|
||||||
|
{
|
||||||
|
Id = "card-no-hash",
|
||||||
|
OracleId = "oracle-hash",
|
||||||
|
SetId = "set-hash",
|
||||||
|
SetCode = "TST",
|
||||||
|
Name = "No Hash",
|
||||||
|
Hash = null
|
||||||
|
};
|
||||||
|
|
||||||
|
await _database.InsertCardAsync(cardWithHash);
|
||||||
|
await _database.InsertCardAsync(cardWithoutHash);
|
||||||
|
|
||||||
|
var cardsWithHash = await _database.GetCardsWithHashAsync();
|
||||||
|
|
||||||
|
Assert.Single(cardsWithHash);
|
||||||
|
Assert.Equal("card-with-hash", cardsWithHash[0].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_database.Dispose();
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_dbPath))
|
||||||
|
{
|
||||||
|
File.Delete(_dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Scry.Core.Data;
|
|
||||||
using Scry.Core.Models;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Scry.Tests;
|
|
||||||
|
|
||||||
public class CardHashDatabaseTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly string _dbPath;
|
|
||||||
private readonly CardHashDatabase _database;
|
|
||||||
|
|
||||||
public CardHashDatabaseTests()
|
|
||||||
{
|
|
||||||
_dbPath = Path.Combine(Path.GetTempPath(), $"scry_test_{Guid.NewGuid()}.db");
|
|
||||||
_database = new CardHashDatabase(_dbPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task InsertHash_ThenRetrieve_ReturnsMatch()
|
|
||||||
{
|
|
||||||
var hash = new CardHash
|
|
||||||
{
|
|
||||||
CardId = "test-id",
|
|
||||||
Name = "Test Card",
|
|
||||||
SetCode = "TST",
|
|
||||||
CollectorNumber = "1",
|
|
||||||
Hash = new byte[] { 0x01, 0x02, 0x03 },
|
|
||||||
ImageUri = "https://example.com/image.jpg"
|
|
||||||
};
|
|
||||||
|
|
||||||
await _database.InsertHashAsync(hash);
|
|
||||||
var retrieved = await _database.GetHashByIdAsync("test-id");
|
|
||||||
|
|
||||||
Assert.NotNull(retrieved);
|
|
||||||
Assert.Equal("Test Card", retrieved.Name);
|
|
||||||
Assert.Equal("TST", retrieved.SetCode);
|
|
||||||
Assert.Equal(hash.Hash, retrieved.Hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task InsertHashBatch_InsertsAllHashes()
|
|
||||||
{
|
|
||||||
var hashes = Enumerable.Range(0, 100).Select(i => new CardHash
|
|
||||||
{
|
|
||||||
CardId = $"card-{i}",
|
|
||||||
Name = $"Card {i}",
|
|
||||||
SetCode = "TST",
|
|
||||||
Hash = new byte[] { (byte)i }
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
await _database.InsertHashBatchAsync(hashes);
|
|
||||||
var count = await _database.GetHashCountAsync();
|
|
||||||
|
|
||||||
Assert.Equal(100, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetAllHashes_ReturnsAllHashes()
|
|
||||||
{
|
|
||||||
var hashes = Enumerable.Range(0, 10).Select(i => new CardHash
|
|
||||||
{
|
|
||||||
CardId = $"card-{i}",
|
|
||||||
Name = $"Card {i}",
|
|
||||||
SetCode = "TST",
|
|
||||||
Hash = new byte[] { (byte)i }
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
await _database.InsertHashBatchAsync(hashes);
|
|
||||||
var all = await _database.GetAllHashesAsync();
|
|
||||||
|
|
||||||
Assert.Equal(10, all.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Metadata_SetAndGet()
|
|
||||||
{
|
|
||||||
await _database.SetMetadataAsync("test_key", "test_value");
|
|
||||||
var value = await _database.GetMetadataAsync("test_key");
|
|
||||||
|
|
||||||
Assert.Equal("test_value", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Clear_RemovesAllHashes()
|
|
||||||
{
|
|
||||||
var hashes = Enumerable.Range(0, 10).Select(i => new CardHash
|
|
||||||
{
|
|
||||||
CardId = $"card-{i}",
|
|
||||||
Name = $"Card {i}",
|
|
||||||
SetCode = "TST",
|
|
||||||
Hash = new byte[] { (byte)i }
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
await _database.InsertHashBatchAsync(hashes);
|
|
||||||
await _database.ClearAsync();
|
|
||||||
var count = await _database.GetHashCountAsync();
|
|
||||||
|
|
||||||
Assert.Equal(0, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task InsertHash_DuplicateId_Updates()
|
|
||||||
{
|
|
||||||
var hash1 = new CardHash
|
|
||||||
{
|
|
||||||
CardId = "duplicate-id",
|
|
||||||
Name = "Original Name",
|
|
||||||
SetCode = "TST",
|
|
||||||
Hash = new byte[] { 0x01 }
|
|
||||||
};
|
|
||||||
|
|
||||||
var hash2 = new CardHash
|
|
||||||
{
|
|
||||||
CardId = "duplicate-id",
|
|
||||||
Name = "Updated Name",
|
|
||||||
SetCode = "TST",
|
|
||||||
Hash = new byte[] { 0x02 }
|
|
||||||
};
|
|
||||||
|
|
||||||
await _database.InsertHashAsync(hash1);
|
|
||||||
await _database.InsertHashAsync(hash2);
|
|
||||||
|
|
||||||
var retrieved = await _database.GetHashByIdAsync("duplicate-id");
|
|
||||||
|
|
||||||
Assert.NotNull(retrieved);
|
|
||||||
Assert.Equal("Updated Name", retrieved.Name);
|
|
||||||
Assert.Equal(new byte[] { 0x02 }, retrieved.Hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_database.Dispose();
|
|
||||||
SqliteConnection.ClearAllPools();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (File.Exists(_dbPath))
|
|
||||||
{
|
|
||||||
File.Delete(_dbPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (IOException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,14 +13,14 @@ public class CardRecognitionTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly ITestOutputHelper _output;
|
private readonly ITestOutputHelper _output;
|
||||||
private readonly string _dbPath;
|
private readonly string _dbPath;
|
||||||
private readonly CardHashDatabase _database;
|
private readonly CardDatabase _database;
|
||||||
private readonly CardRecognitionService _recognitionService;
|
private readonly CardRecognitionService _recognitionService;
|
||||||
|
|
||||||
public CardRecognitionTests(ITestOutputHelper output)
|
public CardRecognitionTests(ITestOutputHelper output)
|
||||||
{
|
{
|
||||||
_output = output;
|
_output = output;
|
||||||
_dbPath = Path.Combine(Path.GetTempPath(), $"scry_recognition_test_{Guid.NewGuid()}.db");
|
_dbPath = Path.Combine(Path.GetTempPath(), $"scry_recognition_test_{Guid.NewGuid()}.db");
|
||||||
_database = new CardHashDatabase(_dbPath);
|
_database = new CardDatabase(_dbPath);
|
||||||
_recognitionService = new CardRecognitionService(_database);
|
_recognitionService = new CardRecognitionService(_database);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ public class CardRecognitionTests : IDisposable
|
||||||
var result = await _recognitionService.RecognizeAsync(bitmap);
|
var result = await _recognitionService.RecognizeAsync(bitmap);
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
Assert.Contains("No card hashes", result.ErrorMessage);
|
Assert.Contains("No cards", result.ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -41,11 +41,17 @@ public class CardRecognitionTests : IDisposable
|
||||||
using var bitmap = CreateTestBitmap(100, 100);
|
using var bitmap = CreateTestBitmap(100, 100);
|
||||||
var hash = _recognitionService.ComputeHash(bitmap);
|
var hash = _recognitionService.ComputeHash(bitmap);
|
||||||
|
|
||||||
await _database.InsertHashAsync(new CardHash
|
// Insert oracle and set first
|
||||||
|
await _database.InsertOracleAsync(new Oracle { Id = "oracle-test", Name = "Test Card" });
|
||||||
|
await _database.InsertSetAsync(new Set { Id = "set-test", Code = "TST", Name = "Test Set" });
|
||||||
|
|
||||||
|
await _database.InsertCardAsync(new Card
|
||||||
{
|
{
|
||||||
CardId = "test-card",
|
Id = "test-card",
|
||||||
Name = "Test Card",
|
OracleId = "oracle-test",
|
||||||
|
SetId = "set-test",
|
||||||
SetCode = "TST",
|
SetCode = "TST",
|
||||||
|
Name = "Test Card",
|
||||||
Hash = hash,
|
Hash = hash,
|
||||||
ImageUri = "https://example.com/test.jpg"
|
ImageUri = "https://example.com/test.jpg"
|
||||||
});
|
});
|
||||||
|
|
@ -78,11 +84,16 @@ public class CardRecognitionTests : IDisposable
|
||||||
var hash = _recognitionService.ComputeHash(bitmap);
|
var hash = _recognitionService.ComputeHash(bitmap);
|
||||||
var cardName = Path.GetFileNameWithoutExtension(imagePath);
|
var cardName = Path.GetFileNameWithoutExtension(imagePath);
|
||||||
|
|
||||||
await _database.InsertHashAsync(new CardHash
|
await _database.InsertOracleAsync(new Oracle { Id = $"oracle-{cardName}", Name = cardName });
|
||||||
|
await _database.InsertSetAsync(new Set { Id = "set-ref", Code = "REF", Name = "Reference Set" });
|
||||||
|
|
||||||
|
await _database.InsertCardAsync(new Card
|
||||||
{
|
{
|
||||||
CardId = cardName,
|
Id = cardName,
|
||||||
Name = cardName,
|
OracleId = $"oracle-{cardName}",
|
||||||
|
SetId = "set-ref",
|
||||||
SetCode = "REF",
|
SetCode = "REF",
|
||||||
|
Name = cardName,
|
||||||
Hash = hash
|
Hash = hash
|
||||||
});
|
});
|
||||||
await _recognitionService.InvalidateCacheAsync();
|
await _recognitionService.InvalidateCacheAsync();
|
||||||
|
|
@ -121,7 +132,7 @@ public class CardRecognitionTests : IDisposable
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var testDb = new CardHashDatabase(dbPath);
|
using var testDb = new CardDatabase(dbPath);
|
||||||
using var testRecognition = new CardRecognitionService(testDb);
|
using var testRecognition = new CardRecognitionService(testDb);
|
||||||
|
|
||||||
using var bitmap = SKBitmap.Decode(imagePath);
|
using var bitmap = SKBitmap.Decode(imagePath);
|
||||||
|
|
@ -129,31 +140,31 @@ public class CardRecognitionTests : IDisposable
|
||||||
|
|
||||||
// First, just compute hash and check distance manually
|
// First, just compute hash and check distance manually
|
||||||
var queryHash = testRecognition.ComputeHash(bitmap);
|
var queryHash = testRecognition.ComputeHash(bitmap);
|
||||||
var allHashes = await testDb.GetAllHashesAsync();
|
var allCards = await testDb.GetCardsWithHashAsync();
|
||||||
|
|
||||||
_output.WriteLine($"Query hash length: {queryHash.Length} bytes");
|
_output.WriteLine($"Query hash length: {queryHash.Length} bytes");
|
||||||
_output.WriteLine($"Database has {allHashes.Count} cards");
|
_output.WriteLine($"Database has {allCards.Count} cards with hashes");
|
||||||
|
|
||||||
// Find Serra Angel and compute distance
|
// Find Serra Angel and compute distance
|
||||||
var serraHash = allHashes.FirstOrDefault(h => h.Name == "Serra Angel");
|
var serraCard = allCards.FirstOrDefault(c => c.Name == "Serra Angel");
|
||||||
if (serraHash != null)
|
if (serraCard?.Hash != null)
|
||||||
{
|
{
|
||||||
var distance = PerceptualHash.HammingDistance(queryHash, serraHash.Hash);
|
var distance = PerceptualHash.HammingDistance(queryHash, serraCard.Hash);
|
||||||
_output.WriteLine($"Serra Angel hash length: {serraHash.Hash.Length} bytes");
|
_output.WriteLine($"Serra Angel hash length: {serraCard.Hash.Length} bytes");
|
||||||
_output.WriteLine($"Distance to Serra Angel: {distance}");
|
_output.WriteLine($"Distance to Serra Angel: {distance}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the actual best match
|
// Find the actual best match
|
||||||
int bestDistance = int.MaxValue;
|
int bestDistance = int.MaxValue;
|
||||||
string? bestName = null;
|
string? bestName = null;
|
||||||
foreach (var hash in allHashes)
|
foreach (var card in allCards)
|
||||||
{
|
{
|
||||||
if (hash.Hash.Length != queryHash.Length) continue;
|
if (card.Hash == null || card.Hash.Length != queryHash.Length) continue;
|
||||||
var dist = PerceptualHash.HammingDistance(queryHash, hash.Hash);
|
var dist = PerceptualHash.HammingDistance(queryHash, card.Hash);
|
||||||
if (dist < bestDistance)
|
if (dist < bestDistance)
|
||||||
{
|
{
|
||||||
bestDistance = dist;
|
bestDistance = dist;
|
||||||
bestName = hash.Name;
|
bestName = card.Name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_output.WriteLine($"Best match: {bestName}, distance: {bestDistance}");
|
_output.WriteLine($"Best match: {bestName}, distance: {bestDistance}");
|
||||||
|
|
@ -180,11 +191,16 @@ public class CardRecognitionTests : IDisposable
|
||||||
using var bitmap = CreateTestBitmap(200, 300);
|
using var bitmap = CreateTestBitmap(200, 300);
|
||||||
var hash = _recognitionService.ComputeHash(bitmap);
|
var hash = _recognitionService.ComputeHash(bitmap);
|
||||||
|
|
||||||
await _database.InsertHashAsync(new CardHash
|
await _database.InsertOracleAsync(new Oracle { Id = "oracle-timing", Name = "Timing Test Card" });
|
||||||
|
await _database.InsertSetAsync(new Set { Id = "set-timing", Code = "TST", Name = "Test Set" });
|
||||||
|
|
||||||
|
await _database.InsertCardAsync(new Card
|
||||||
{
|
{
|
||||||
CardId = "timing-test",
|
Id = "timing-test",
|
||||||
Name = "Timing Test Card",
|
OracleId = "oracle-timing",
|
||||||
|
SetId = "set-timing",
|
||||||
SetCode = "TST",
|
SetCode = "TST",
|
||||||
|
Name = "Timing Test Card",
|
||||||
Hash = hash
|
Hash = hash
|
||||||
});
|
});
|
||||||
await _recognitionService.InvalidateCacheAsync();
|
await _recognitionService.InvalidateCacheAsync();
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,14 @@ public class RobustnessAnalysisTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly ITestOutputHelper _output;
|
private readonly ITestOutputHelper _output;
|
||||||
private readonly string _dbPath;
|
private readonly string _dbPath;
|
||||||
private readonly CardHashDatabase _database;
|
private readonly CardDatabase _database;
|
||||||
private readonly CardRecognitionService _recognitionService;
|
private readonly CardRecognitionService _recognitionService;
|
||||||
|
|
||||||
public RobustnessAnalysisTests(ITestOutputHelper output)
|
public RobustnessAnalysisTests(ITestOutputHelper output)
|
||||||
{
|
{
|
||||||
_output = output;
|
_output = output;
|
||||||
_dbPath = Path.Combine(Path.GetTempPath(), $"scry_robustness_test_{Guid.NewGuid()}.db");
|
_dbPath = Path.Combine(Path.GetTempPath(), $"scry_robustness_test_{Guid.NewGuid()}.db");
|
||||||
_database = new CardHashDatabase(_dbPath);
|
_database = new CardDatabase(_dbPath);
|
||||||
_recognitionService = new CardRecognitionService(_database);
|
_recognitionService = new CardRecognitionService(_database);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,11 +52,16 @@ public class RobustnessAnalysisTests : IDisposable
|
||||||
var originalHash = _recognitionService.ComputeHash(original);
|
var originalHash = _recognitionService.ComputeHash(original);
|
||||||
|
|
||||||
// Register original in database
|
// Register original in database
|
||||||
await _database.InsertHashAsync(new CardHash
|
await _database.InsertOracleAsync(new Oracle { Id = "oracle-serra", Name = "Serra Angel" });
|
||||||
|
await _database.InsertSetAsync(new Set { Id = "set-lea", Code = "LEA", Name = "Alpha" });
|
||||||
|
|
||||||
|
await _database.InsertCardAsync(new Card
|
||||||
{
|
{
|
||||||
CardId = "serra-angel",
|
Id = "serra-angel",
|
||||||
Name = "Serra Angel",
|
OracleId = "oracle-serra",
|
||||||
|
SetId = "set-lea",
|
||||||
SetCode = "LEA",
|
SetCode = "LEA",
|
||||||
|
Name = "Serra Angel",
|
||||||
Hash = originalHash
|
Hash = originalHash
|
||||||
});
|
});
|
||||||
await _recognitionService.InvalidateCacheAsync();
|
await _recognitionService.InvalidateCacheAsync();
|
||||||
|
|
@ -113,11 +118,16 @@ public class RobustnessAnalysisTests : IDisposable
|
||||||
|
|
||||||
var originalHash = _recognitionService.ComputeHash(original);
|
var originalHash = _recognitionService.ComputeHash(original);
|
||||||
|
|
||||||
await _database.InsertHashAsync(new CardHash
|
await _database.InsertOracleAsync(new Oracle { Id = "oracle-serra", Name = "Serra Angel" });
|
||||||
|
await _database.InsertSetAsync(new Set { Id = "set-lea", Code = "LEA", Name = "Alpha" });
|
||||||
|
|
||||||
|
await _database.InsertCardAsync(new Card
|
||||||
{
|
{
|
||||||
CardId = "serra-angel",
|
Id = "serra-angel",
|
||||||
Name = "Serra Angel",
|
OracleId = "oracle-serra",
|
||||||
|
SetId = "set-lea",
|
||||||
SetCode = "LEA",
|
SetCode = "LEA",
|
||||||
|
Name = "Serra Angel",
|
||||||
Hash = originalHash
|
Hash = originalHash
|
||||||
});
|
});
|
||||||
await _recognitionService.InvalidateCacheAsync();
|
await _recognitionService.InvalidateCacheAsync();
|
||||||
|
|
@ -167,11 +177,16 @@ public class RobustnessAnalysisTests : IDisposable
|
||||||
|
|
||||||
var originalHash = _recognitionService.ComputeHash(original);
|
var originalHash = _recognitionService.ComputeHash(original);
|
||||||
|
|
||||||
await _database.InsertHashAsync(new CardHash
|
await _database.InsertOracleAsync(new Oracle { Id = "oracle-serra", Name = "Serra Angel" });
|
||||||
|
await _database.InsertSetAsync(new Set { Id = "set-lea", Code = "LEA", Name = "Alpha" });
|
||||||
|
|
||||||
|
await _database.InsertCardAsync(new Card
|
||||||
{
|
{
|
||||||
CardId = "serra-angel",
|
Id = "serra-angel",
|
||||||
Name = "Serra Angel",
|
OracleId = "oracle-serra",
|
||||||
|
SetId = "set-lea",
|
||||||
SetCode = "LEA",
|
SetCode = "LEA",
|
||||||
|
Name = "Serra Angel",
|
||||||
Hash = originalHash
|
Hash = originalHash
|
||||||
});
|
});
|
||||||
await _recognitionService.InvalidateCacheAsync();
|
await _recognitionService.InvalidateCacheAsync();
|
||||||
|
|
@ -218,11 +233,16 @@ public class RobustnessAnalysisTests : IDisposable
|
||||||
|
|
||||||
var originalHash = _recognitionService.ComputeHash(original);
|
var originalHash = _recognitionService.ComputeHash(original);
|
||||||
|
|
||||||
await _database.InsertHashAsync(new CardHash
|
await _database.InsertOracleAsync(new Oracle { Id = "oracle-serra", Name = "Serra Angel" });
|
||||||
|
await _database.InsertSetAsync(new Set { Id = "set-lea", Code = "LEA", Name = "Alpha" });
|
||||||
|
|
||||||
|
await _database.InsertCardAsync(new Card
|
||||||
{
|
{
|
||||||
CardId = "serra-angel",
|
Id = "serra-angel",
|
||||||
Name = "Serra Angel",
|
OracleId = "oracle-serra",
|
||||||
|
SetId = "set-lea",
|
||||||
SetCode = "LEA",
|
SetCode = "LEA",
|
||||||
|
Name = "Serra Angel",
|
||||||
Hash = originalHash
|
Hash = originalHash
|
||||||
});
|
});
|
||||||
await _recognitionService.InvalidateCacheAsync();
|
await _recognitionService.InvalidateCacheAsync();
|
||||||
|
|
@ -265,11 +285,16 @@ public class RobustnessAnalysisTests : IDisposable
|
||||||
|
|
||||||
var originalHash = _recognitionService.ComputeHash(original);
|
var originalHash = _recognitionService.ComputeHash(original);
|
||||||
|
|
||||||
await _database.InsertHashAsync(new CardHash
|
await _database.InsertOracleAsync(new Oracle { Id = "oracle-serra", Name = "Serra Angel" });
|
||||||
|
await _database.InsertSetAsync(new Set { Id = "set-lea", Code = "LEA", Name = "Alpha" });
|
||||||
|
|
||||||
|
await _database.InsertCardAsync(new Card
|
||||||
{
|
{
|
||||||
CardId = "serra-angel",
|
Id = "serra-angel",
|
||||||
Name = "Serra Angel",
|
OracleId = "oracle-serra",
|
||||||
|
SetId = "set-lea",
|
||||||
SetCode = "LEA",
|
SetCode = "LEA",
|
||||||
|
Name = "Serra Angel",
|
||||||
Hash = originalHash
|
Hash = originalHash
|
||||||
});
|
});
|
||||||
await _recognitionService.InvalidateCacheAsync();
|
await _recognitionService.InvalidateCacheAsync();
|
||||||
|
|
@ -312,14 +337,14 @@ public class RobustnessAnalysisTests : IDisposable
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var prodDb = new CardHashDatabase(dbPath);
|
using var prodDb = new CardDatabase(dbPath);
|
||||||
using var prodRecognition = new CardRecognitionService(prodDb);
|
using var prodRecognition = new CardRecognitionService(prodDb);
|
||||||
|
|
||||||
var testImagesDir = Path.Combine(rootDir, "TestImages");
|
var testImagesDir = Path.Combine(rootDir, "TestImages");
|
||||||
var categoriesToTest = new[] { "real_photos", "varying_quality", "angled", "low_light" };
|
var categoriesToTest = new[] { "real_photos", "varying_quality", "angled", "low_light" };
|
||||||
|
|
||||||
_output.WriteLine("=== Real-World Recognition Test ===");
|
_output.WriteLine("=== Real-World Recognition Test ===");
|
||||||
_output.WriteLine($"Database cards: {(await prodDb.GetAllHashesAsync()).Count}");
|
_output.WriteLine($"Database cards: {(await prodDb.GetCardsWithHashAsync()).Count}");
|
||||||
_output.WriteLine("");
|
_output.WriteLine("");
|
||||||
|
|
||||||
foreach (var category in categoriesToTest)
|
foreach (var category in categoriesToTest)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="SkiaSharp" />
|
<PackageReference Include="SkiaSharp" />
|
||||||
|
<PackageReference Include="Spectre.Console" />
|
||||||
|
<PackageReference Include="Spectre.Console.Cli" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
495
tools/DbGenerator/GenerateCommand.cs
Normal file
495
tools/DbGenerator/GenerateCommand.cs
Normal file
|
|
@ -0,0 +1,495 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
using Scry.Core.Data;
|
||||||
|
using Scry.Core.Imaging;
|
||||||
|
using Scry.Core.Models;
|
||||||
|
using Scry.Core.Scryfall;
|
||||||
|
using SkiaSharp;
|
||||||
|
using Spectre.Console;
|
||||||
|
using Spectre.Console.Cli;
|
||||||
|
|
||||||
|
namespace DbGenerator;
|
||||||
|
|
||||||
|
public sealed class GenerateSettings : CommandSettings
|
||||||
|
{
|
||||||
|
[CommandArgument(0, "[output]")]
|
||||||
|
[Description("Output database file path")]
|
||||||
|
[DefaultValue("card_hashes.db")]
|
||||||
|
public string Output { get; set; } = "card_hashes.db";
|
||||||
|
|
||||||
|
[CommandOption("-c|--count")]
|
||||||
|
[Description("Maximum number of cards to include")]
|
||||||
|
[DefaultValue(500)]
|
||||||
|
public int Count { get; set; } = 500;
|
||||||
|
|
||||||
|
[CommandOption("--include-test-cards")]
|
||||||
|
[Description("Include priority test cards (default: true)")]
|
||||||
|
[DefaultValue(true)]
|
||||||
|
public bool IncludeTestCards { get; set; } = true;
|
||||||
|
|
||||||
|
[CommandOption("--no-test-cards")]
|
||||||
|
[Description("Exclude priority test cards")]
|
||||||
|
public bool NoTestCards { get; set; }
|
||||||
|
|
||||||
|
[CommandOption("-f|--force")]
|
||||||
|
[Description("Force rebuild from scratch")]
|
||||||
|
public bool Force { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GenerateCommand : AsyncCommand<GenerateSettings>
|
||||||
|
{
|
||||||
|
// Cards that should be included for testing with preferred sets
|
||||||
|
private static readonly Dictionary<string, string[]> PriorityCardsWithSets = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
// From reference_alpha/ - prefer LEA (Alpha) or LEB (Beta) for classic look
|
||||||
|
["Ancestral Recall"] = ["lea", "leb"],
|
||||||
|
["Badlands"] = ["lea", "leb"],
|
||||||
|
["Balance"] = ["lea", "leb"],
|
||||||
|
["Bayou"] = ["lea", "leb"],
|
||||||
|
["Birds of Paradise"] = ["lea", "leb"],
|
||||||
|
["Black Lotus"] = ["lea", "leb"],
|
||||||
|
["Channel"] = ["lea", "leb"],
|
||||||
|
["Chaos Orb"] = ["lea", "leb"],
|
||||||
|
["Clone"] = ["lea", "leb"],
|
||||||
|
["Control Magic"] = ["lea", "leb"],
|
||||||
|
["Counterspell"] = ["lea", "leb"],
|
||||||
|
["Dark Ritual"] = ["lea", "leb"],
|
||||||
|
["Demonic Tutor"] = ["lea", "leb"],
|
||||||
|
["Disenchant"] = ["lea", "leb"],
|
||||||
|
["Fireball"] = ["lea", "leb"],
|
||||||
|
["Force of Nature"] = ["lea", "leb"],
|
||||||
|
["Fork"] = ["lea", "leb"],
|
||||||
|
["Giant Growth"] = ["lea", "leb"],
|
||||||
|
["Hypnotic Specter"] = ["lea", "leb"],
|
||||||
|
["Lightning Bolt"] = ["lea", "leb"],
|
||||||
|
["Llanowar Elves"] = ["lea", "leb"],
|
||||||
|
["Mahamoti Djinn"] = ["lea", "leb"],
|
||||||
|
["Mind Twist"] = ["lea", "leb"],
|
||||||
|
["Mox Emerald"] = ["lea", "leb"],
|
||||||
|
["Mox Jet"] = ["lea", "leb"],
|
||||||
|
["Mox Pearl"] = ["lea", "leb"],
|
||||||
|
["Mox Ruby"] = ["lea", "leb"],
|
||||||
|
["Mox Sapphire"] = ["lea", "leb"],
|
||||||
|
["Nightmare"] = ["lea", "leb"],
|
||||||
|
["Plateau"] = ["lea", "leb"],
|
||||||
|
["Regrowth"] = ["lea", "leb"],
|
||||||
|
["Rock Hydra"] = ["lea", "leb"],
|
||||||
|
["Royal Assassin"] = ["lea", "leb"],
|
||||||
|
["Savannah"] = ["lea", "leb"],
|
||||||
|
["Scrubland"] = ["lea", "leb"],
|
||||||
|
["Serra Angel"] = ["lea", "leb"],
|
||||||
|
["Shivan Dragon"] = ["lea", "leb"],
|
||||||
|
["Sol Ring"] = ["lea", "leb"],
|
||||||
|
["Swords to Plowshares"] = ["lea", "leb"],
|
||||||
|
["Taiga"] = ["lea", "leb"],
|
||||||
|
["Time Walk"] = ["lea", "leb"],
|
||||||
|
["Timetwister"] = ["lea", "leb"],
|
||||||
|
["Tropical Island"] = ["lea", "leb"],
|
||||||
|
["Tundra"] = ["lea", "leb"],
|
||||||
|
["Underground Sea"] = ["lea", "leb"],
|
||||||
|
["Wheel of Fortune"] = ["lea", "leb"],
|
||||||
|
["Wrath of God"] = ["lea", "leb"],
|
||||||
|
|
||||||
|
// From reference/ - any set is fine
|
||||||
|
["Brainstorm"] = [],
|
||||||
|
["Force of Will"] = [],
|
||||||
|
["Griselbrand"] = [],
|
||||||
|
["Lotus Petal"] = [],
|
||||||
|
["Ponder"] = [],
|
||||||
|
["Show and Tell"] = [],
|
||||||
|
["Volcanic Island"] = [],
|
||||||
|
["Wasteland"] = [],
|
||||||
|
|
||||||
|
// From single_cards/ - any set is fine
|
||||||
|
["Adanto Vanguard"] = [],
|
||||||
|
["Angel of Sanctions"] = [],
|
||||||
|
["Attunement"] = [],
|
||||||
|
["Avaricious Dragon"] = [],
|
||||||
|
["Burgeoning"] = [],
|
||||||
|
["Jarad, Golgari Lich Lord"] = [],
|
||||||
|
["Meletis Charlatan"] = [],
|
||||||
|
["Mindstab Thrull"] = [],
|
||||||
|
["Pacifism"] = [],
|
||||||
|
["Platinum Angel"] = [],
|
||||||
|
["Queen Marchesa"] = [],
|
||||||
|
["Spellseeker"] = [],
|
||||||
|
["Tarmogoyf"] = [],
|
||||||
|
["Thought Reflection"] = [],
|
||||||
|
["Unsummon"] = [],
|
||||||
|
|
||||||
|
// From varying_quality - prefer older sets
|
||||||
|
["Dragon Whelp"] = ["lea", "leb"],
|
||||||
|
["Evil Eye of Orms-by-Gore"] = [],
|
||||||
|
["Instill Energy"] = ["lea", "leb"],
|
||||||
|
|
||||||
|
// Popular cards for general testing
|
||||||
|
["Lightning Helix"] = [],
|
||||||
|
["Path to Exile"] = [],
|
||||||
|
["Thoughtseize"] = [],
|
||||||
|
["Fatal Push"] = [],
|
||||||
|
["Snapcaster Mage"] = [],
|
||||||
|
["Jace, the Mind Sculptor"] = [],
|
||||||
|
["Liliana of the Veil"] = [],
|
||||||
|
["Noble Hierarch"] = [],
|
||||||
|
["Goblin Guide"] = [],
|
||||||
|
["Eidolon of the Great Revel"] = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
public override async Task<int> ExecuteAsync(CommandContext context, GenerateSettings settings)
|
||||||
|
{
|
||||||
|
var outputDb = settings.Output;
|
||||||
|
var maxCards = settings.Count;
|
||||||
|
var includeTestCards = settings.IncludeTestCards && !settings.NoTestCards;
|
||||||
|
var forceRebuild = settings.Force;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
AnsiConsole.Write(new FigletText("Scry DB Gen").Color(Color.Blue));
|
||||||
|
|
||||||
|
var configTable = new Table()
|
||||||
|
.Border(TableBorder.Rounded)
|
||||||
|
.AddColumn("Setting")
|
||||||
|
.AddColumn("Value");
|
||||||
|
|
||||||
|
configTable.AddRow("Output", outputDb);
|
||||||
|
configTable.AddRow("Max Cards", maxCards.ToString());
|
||||||
|
configTable.AddRow("Test Cards", includeTestCards ? "[green]Yes[/]" : "[grey]No[/]");
|
||||||
|
configTable.AddRow("Force Rebuild", forceRebuild ? "[yellow]Yes[/]" : "[grey]No[/]");
|
||||||
|
|
||||||
|
AnsiConsole.Write(configTable);
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
var priorityCards = new HashSet<string>(PriorityCardsWithSets.Keys, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Force rebuild if requested
|
||||||
|
if (forceRebuild && File.Exists(outputDb))
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[yellow]Force rebuild requested, removing existing database...[/]");
|
||||||
|
File.Delete(outputDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var httpClient = new HttpClient();
|
||||||
|
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Scry/1.0 (MTG Card Scanner - Database Generator)");
|
||||||
|
|
||||||
|
using var scryfallClient = new ScryfallClient(httpClient);
|
||||||
|
using var db = new CardDatabase(outputDb);
|
||||||
|
|
||||||
|
// Check existing database state
|
||||||
|
var existingCardIds = await db.GetExistingCardIdsAsync();
|
||||||
|
var existingCardNames = await db.GetExistingCardNamesAsync();
|
||||||
|
var existingOracleIds = await db.GetExistingOracleIdsAsync();
|
||||||
|
var existingSetIds = await db.GetExistingSetIdsAsync();
|
||||||
|
var existingCount = await db.GetCardCountAsync();
|
||||||
|
var storedScryfallDate = await db.GetMetadataAsync("scryfall_updated_at");
|
||||||
|
|
||||||
|
var dbStateTable = new Table()
|
||||||
|
.Border(TableBorder.Rounded)
|
||||||
|
.Title("[blue]Current Database State[/]")
|
||||||
|
.AddColumn("Metric")
|
||||||
|
.AddColumn("Count", c => c.RightAligned());
|
||||||
|
|
||||||
|
dbStateTable.AddRow("Cards", existingCount.ToString());
|
||||||
|
dbStateTable.AddRow("Oracles", existingOracleIds.Count.ToString());
|
||||||
|
dbStateTable.AddRow("Sets", existingSetIds.Count.ToString());
|
||||||
|
|
||||||
|
AnsiConsole.Write(dbStateTable);
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
// Fetch all sets
|
||||||
|
List<ScryfallSet> scryfallSets = [];
|
||||||
|
await AnsiConsole.Status()
|
||||||
|
.Spinner(Spinner.Known.Dots)
|
||||||
|
.StartAsync("Fetching sets from Scryfall...", async ctx =>
|
||||||
|
{
|
||||||
|
scryfallSets = await scryfallClient.GetAllSetsAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine($"[green]✓[/] Found [blue]{scryfallSets.Count}[/] sets");
|
||||||
|
|
||||||
|
var setsById = scryfallSets.ToDictionary(s => s.Id ?? "", s => s);
|
||||||
|
var setsByCode = scryfallSets.ToDictionary(s => s.Code ?? "", s => s, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Insert any new sets
|
||||||
|
var newSets = scryfallSets
|
||||||
|
.Where(s => s.Id != null && !existingSetIds.Contains(s.Id))
|
||||||
|
.Select(s => s.ToSet())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (newSets.Count > 0)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[green]✓[/] Inserting [blue]{newSets.Count}[/] new sets");
|
||||||
|
await db.InsertSetBatchAsync(newSets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch bulk data info
|
||||||
|
BulkDataInfo? bulkInfo = null;
|
||||||
|
await AnsiConsole.Status()
|
||||||
|
.Spinner(Spinner.Known.Dots)
|
||||||
|
.StartAsync("Fetching bulk data info...", async ctx =>
|
||||||
|
{
|
||||||
|
bulkInfo = await scryfallClient.GetBulkDataInfoAsync("unique_artwork");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bulkInfo?.DownloadUri == null)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[red]✗ Failed to get bulk data info from Scryfall[/]");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine($"[green]✓[/] Scryfall data last updated: [blue]{bulkInfo.UpdatedAt:yyyy-MM-dd HH:mm}[/]");
|
||||||
|
|
||||||
|
// Check if we need to update at all
|
||||||
|
var scryfallDateStr = bulkInfo.UpdatedAt?.ToString("O") ?? "";
|
||||||
|
var needsUpdate = existingCount == 0 ||
|
||||||
|
storedScryfallDate != scryfallDateStr ||
|
||||||
|
existingCount < maxCards;
|
||||||
|
|
||||||
|
// Also check if all priority cards exist
|
||||||
|
var missingPriorityCards = includeTestCards
|
||||||
|
? priorityCards.Where(c => !existingCardNames.Contains(c)).ToList()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (missingPriorityCards is not [])
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[yellow]![/] Missing [blue]{missingPriorityCards.Count}[/] priority cards");
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsUpdate)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[green]✓ Database is up-to-date, no changes needed[/]");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
var newCards = new List<Card>();
|
||||||
|
var newOracles = new Dictionary<string, Oracle>();
|
||||||
|
var processed = 0;
|
||||||
|
var errors = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
var priorityFound = 0;
|
||||||
|
var priorityNeeded = includeTestCards ? priorityCards.Count : 0;
|
||||||
|
|
||||||
|
// Track which priority cards we've already found with their set
|
||||||
|
var foundPriorityWithSet = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Helper to check if a set is preferred for a priority card
|
||||||
|
static bool IsPreferredSet(string cardName, string setCode)
|
||||||
|
{
|
||||||
|
if (!PriorityCardsWithSets.TryGetValue(cardName, out var preferredSets))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return preferredSets.Length == 0 || preferredSets.Contains(setCode, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
await AnsiConsole.Progress()
|
||||||
|
.AutoClear(false)
|
||||||
|
.HideCompleted(false)
|
||||||
|
.Columns(
|
||||||
|
new RemainingTimeColumn(),
|
||||||
|
new SpinnerColumn(),
|
||||||
|
new ProgressBarColumn(),
|
||||||
|
new PercentageColumn(),
|
||||||
|
new TaskDescriptionColumn()
|
||||||
|
{
|
||||||
|
Alignment = Justify.Left,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.StartAsync(async ctx =>
|
||||||
|
{
|
||||||
|
var downloadTask = ctx.AddTask("[blue]Downloading & processing cards[/]", maxValue: maxCards);
|
||||||
|
var priorityTask = ctx.AddTask("[green]Priority cards[/]", maxValue: priorityNeeded);
|
||||||
|
|
||||||
|
await foreach (var scryfallCard in scryfallClient.StreamBulkDataAsync(bulkInfo.DownloadUri))
|
||||||
|
{
|
||||||
|
// Skip non-English cards
|
||||||
|
if (scryfallCard.Lang != "en")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var imageUri = scryfallCard.GetImageUri("normal");
|
||||||
|
if (string.IsNullOrEmpty(imageUri))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var cardId = scryfallCard.Id ?? Guid.NewGuid().ToString();
|
||||||
|
var cardName = scryfallCard.Name ?? "Unknown";
|
||||||
|
var setCode = scryfallCard.Set ?? "???";
|
||||||
|
var oracleId = scryfallCard.OracleId ?? cardId;
|
||||||
|
var setId = scryfallCard.SetId ?? "";
|
||||||
|
|
||||||
|
// Check if this card already exists in the database
|
||||||
|
if (existingCardIds.Contains(cardId))
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a priority card we might need
|
||||||
|
var isPriorityCard = includeTestCards && priorityCards.Contains(cardName);
|
||||||
|
var isPreferred = isPriorityCard && IsPreferredSet(cardName, setCode);
|
||||||
|
|
||||||
|
// If this priority card already found with preferred set, skip
|
||||||
|
if (isPriorityCard && foundPriorityWithSet.TryGetValue(cardName, out var existingSet))
|
||||||
|
{
|
||||||
|
if (IsPreferredSet(cardName, existingSet))
|
||||||
|
continue;
|
||||||
|
if (!isPreferred)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how many slots we have left
|
||||||
|
var totalCards = existingCount + newCards.Count;
|
||||||
|
var priorityRemaining = priorityNeeded - foundPriorityWithSet.Count;
|
||||||
|
var slotsForNonPriority = maxCards - priorityRemaining;
|
||||||
|
|
||||||
|
// Skip if we have enough non-priority cards and this isn't priority
|
||||||
|
if (!isPriorityCard && totalCards >= slotsForNonPriority)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Download and process image
|
||||||
|
try
|
||||||
|
{
|
||||||
|
downloadTask.Description = $"[blue]{Markup.Escape(cardName.Length > 30 ? cardName[..27] + "..." : cardName)}[/]";
|
||||||
|
|
||||||
|
var imageBytes = await httpClient.GetByteArrayAsync(imageUri);
|
||||||
|
using var bitmap = SKBitmap.Decode(imageBytes);
|
||||||
|
|
||||||
|
if (bitmap == null)
|
||||||
|
{
|
||||||
|
errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply CLAHE preprocessing and compute hash
|
||||||
|
using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
|
||||||
|
var hash = PerceptualHash.ComputeColorHash(preprocessed);
|
||||||
|
|
||||||
|
// Create Card (printing) with hash
|
||||||
|
var card = scryfallCard.ToCard() with { Hash = hash };
|
||||||
|
newCards.Add(card);
|
||||||
|
|
||||||
|
// Track Oracle if we haven't seen it
|
||||||
|
if (!existingOracleIds.Contains(oracleId) && !newOracles.ContainsKey(oracleId))
|
||||||
|
{
|
||||||
|
newOracles[oracleId] = scryfallCard.ToOracle();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPriorityCard)
|
||||||
|
{
|
||||||
|
foundPriorityWithSet[cardName] = setCode;
|
||||||
|
priorityFound++;
|
||||||
|
priorityTask.Increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
processed++;
|
||||||
|
downloadTask.Increment(1);
|
||||||
|
|
||||||
|
// Check if we have enough cards
|
||||||
|
var foundAllPriority = foundPriorityWithSet.Count >= priorityNeeded;
|
||||||
|
if (existingCount + newCards.Count >= maxCards && foundAllPriority)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Rate limit to be nice to Scryfall
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadTask.Value = downloadTask.MaxValue;
|
||||||
|
priorityTask.Value = priorityTask.MaxValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
// Summary table
|
||||||
|
var summaryTable = new Table()
|
||||||
|
.Border(TableBorder.Rounded)
|
||||||
|
.Title("[blue]Processing Summary[/]")
|
||||||
|
.AddColumn("Metric")
|
||||||
|
.AddColumn("Count", c => c.RightAligned());
|
||||||
|
|
||||||
|
summaryTable.AddRow("Skipped (already in DB)", skipped.ToString());
|
||||||
|
summaryTable.AddRow("Newly processed", $"[green]{processed}[/]");
|
||||||
|
summaryTable.AddRow("New oracles", newOracles.Count.ToString());
|
||||||
|
summaryTable.AddRow("Priority cards found", $"{priorityFound}/{priorityNeeded}");
|
||||||
|
summaryTable.AddRow("Errors", errors > 0 ? $"[red]{errors}[/]" : "0");
|
||||||
|
|
||||||
|
AnsiConsole.Write(summaryTable);
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
// Insert oracles first (cards reference them)
|
||||||
|
if (newOracles.Count > 0)
|
||||||
|
{
|
||||||
|
await AnsiConsole.Status()
|
||||||
|
.Spinner(Spinner.Known.Dots)
|
||||||
|
.StartAsync($"Inserting {newOracles.Count} new oracles...", async ctx =>
|
||||||
|
{
|
||||||
|
await db.InsertOracleBatchAsync(newOracles.Values);
|
||||||
|
});
|
||||||
|
AnsiConsole.MarkupLine($"[green]✓[/] Inserted [blue]{newOracles.Count}[/] oracles");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newCards.Count > 0)
|
||||||
|
{
|
||||||
|
await AnsiConsole.Status()
|
||||||
|
.Spinner(Spinner.Known.Dots)
|
||||||
|
.StartAsync($"Inserting {newCards.Count} new cards...", async ctx =>
|
||||||
|
{
|
||||||
|
await db.InsertCardBatchAsync(newCards);
|
||||||
|
});
|
||||||
|
AnsiConsole.MarkupLine($"[green]✓[/] Inserted [blue]{newCards.Count}[/] cards");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SetMetadataAsync("generated_at", DateTime.UtcNow.ToString("O"));
|
||||||
|
await db.SetMetadataAsync("scryfall_updated_at", scryfallDateStr);
|
||||||
|
|
||||||
|
var finalCardCount = await db.GetCardCountAsync();
|
||||||
|
var finalOracleCount = await db.GetOracleCountAsync();
|
||||||
|
var finalSetCount = await db.GetSetCountAsync();
|
||||||
|
|
||||||
|
await db.SetMetadataAsync("card_count", finalCardCount.ToString());
|
||||||
|
await db.SetMetadataAsync("oracle_count", finalOracleCount.ToString());
|
||||||
|
await db.SetMetadataAsync("set_count", finalSetCount.ToString());
|
||||||
|
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
var finalTable = new Table()
|
||||||
|
.Border(TableBorder.Double)
|
||||||
|
.Title("[green]Final Database State[/]")
|
||||||
|
.AddColumn("Metric")
|
||||||
|
.AddColumn("Count", c => c.RightAligned());
|
||||||
|
|
||||||
|
finalTable.AddRow("Cards", $"[green]{finalCardCount}[/]");
|
||||||
|
finalTable.AddRow("Oracles", $"[green]{finalOracleCount}[/]");
|
||||||
|
finalTable.AddRow("Sets", $"[green]{finalSetCount}[/]");
|
||||||
|
finalTable.AddRow("Output", $"[blue]{outputDb}[/]");
|
||||||
|
|
||||||
|
AnsiConsole.Write(finalTable);
|
||||||
|
|
||||||
|
// Report missing priority cards
|
||||||
|
if (includeTestCards)
|
||||||
|
{
|
||||||
|
var missing = priorityCards.Where(c => !foundPriorityWithSet.ContainsKey(c)).ToList();
|
||||||
|
|
||||||
|
if (missing.Count > 0)
|
||||||
|
{
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
AnsiConsole.MarkupLine($"[yellow]Missing priority cards ({missing.Count}):[/]");
|
||||||
|
|
||||||
|
var tree = new Tree("[yellow]Missing Cards[/]");
|
||||||
|
foreach (var name in missing.Take(20))
|
||||||
|
{
|
||||||
|
tree.AddNode($"[grey]{Markup.Escape(name)}[/]");
|
||||||
|
}
|
||||||
|
if (missing.Count > 20)
|
||||||
|
{
|
||||||
|
tree.AddNode($"[grey]... and {missing.Count - 20} more[/]");
|
||||||
|
}
|
||||||
|
AnsiConsole.Write(tree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,377 +1,12 @@
|
||||||
using Scry.Core.Data;
|
using DbGenerator;
|
||||||
using Scry.Core.Imaging;
|
using Spectre.Console.Cli;
|
||||||
using Scry.Core.Models;
|
|
||||||
using Scry.Core.Scryfall;
|
|
||||||
using SkiaSharp;
|
|
||||||
|
|
||||||
// Generate a card hash database from Scryfall images
|
var app = new CommandApp<GenerateCommand>();
|
||||||
// Usage: dotnet run -- <output-db> [--count N] [--include-test-cards] [--force]
|
|
||||||
|
|
||||||
var outputDb = args.Length > 0 ? args[0] : "card_hashes.db";
|
app.Configure(config =>
|
||||||
var maxCards = 500;
|
|
||||||
var includeTestCards = true;
|
|
||||||
var forceRebuild = false;
|
|
||||||
|
|
||||||
// Parse arguments
|
|
||||||
for (var i = 0; i < args.Length; i++)
|
|
||||||
{
|
{
|
||||||
if (args[i] == "--count" && i + 1 < args.Length && int.TryParse(args[i + 1], out var parsedCount))
|
config.SetApplicationName("dbgen");
|
||||||
{
|
config.SetApplicationVersion("1.0.0");
|
||||||
maxCards = parsedCount;
|
});
|
||||||
i++;
|
|
||||||
}
|
|
||||||
else if (args[i] == "--include-test-cards")
|
|
||||||
{
|
|
||||||
includeTestCards = true;
|
|
||||||
}
|
|
||||||
else if (args[i] == "--no-test-cards")
|
|
||||||
{
|
|
||||||
includeTestCards = false;
|
|
||||||
}
|
|
||||||
else if (args[i] == "--force")
|
|
||||||
{
|
|
||||||
forceRebuild = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine($"Generating hash database with up to {maxCards} cards");
|
return await app.RunAsync(args);
|
||||||
Console.WriteLine($"Output: {outputDb}");
|
|
||||||
Console.WriteLine($"Include test cards: {includeTestCards}");
|
|
||||||
Console.WriteLine($"Force rebuild: {forceRebuild}");
|
|
||||||
Console.WriteLine();
|
|
||||||
|
|
||||||
// Cards that should be included for testing with preferred sets
|
|
||||||
// Key: card name, Value: preferred set codes (first match wins) or empty for any
|
|
||||||
var priorityCardsWithSets = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
// From reference_alpha/ - prefer LEA (Alpha) or LEB (Beta) for classic look
|
|
||||||
["Ancestral Recall"] = ["lea", "leb"],
|
|
||||||
["Badlands"] = ["lea", "leb"],
|
|
||||||
["Balance"] = ["lea", "leb"],
|
|
||||||
["Bayou"] = ["lea", "leb"],
|
|
||||||
["Birds of Paradise"] = ["lea", "leb"],
|
|
||||||
["Black Lotus"] = ["lea", "leb"],
|
|
||||||
["Channel"] = ["lea", "leb"],
|
|
||||||
["Chaos Orb"] = ["lea", "leb"],
|
|
||||||
["Clone"] = ["lea", "leb"],
|
|
||||||
["Control Magic"] = ["lea", "leb"],
|
|
||||||
["Counterspell"] = ["lea", "leb"],
|
|
||||||
["Dark Ritual"] = ["lea", "leb"],
|
|
||||||
["Demonic Tutor"] = ["lea", "leb"],
|
|
||||||
["Disenchant"] = ["lea", "leb"],
|
|
||||||
["Fireball"] = ["lea", "leb"],
|
|
||||||
["Force of Nature"] = ["lea", "leb"],
|
|
||||||
["Fork"] = ["lea", "leb"],
|
|
||||||
["Giant Growth"] = ["lea", "leb"],
|
|
||||||
["Hypnotic Specter"] = ["lea", "leb"],
|
|
||||||
["Lightning Bolt"] = ["lea", "leb"],
|
|
||||||
["Llanowar Elves"] = ["lea", "leb"],
|
|
||||||
["Mahamoti Djinn"] = ["lea", "leb"],
|
|
||||||
["Mind Twist"] = ["lea", "leb"],
|
|
||||||
["Mox Emerald"] = ["lea", "leb"],
|
|
||||||
["Mox Jet"] = ["lea", "leb"],
|
|
||||||
["Mox Pearl"] = ["lea", "leb"],
|
|
||||||
["Mox Ruby"] = ["lea", "leb"],
|
|
||||||
["Mox Sapphire"] = ["lea", "leb"],
|
|
||||||
["Nightmare"] = ["lea", "leb"],
|
|
||||||
["Plateau"] = ["lea", "leb"],
|
|
||||||
["Regrowth"] = ["lea", "leb"],
|
|
||||||
["Rock Hydra"] = ["lea", "leb"],
|
|
||||||
["Royal Assassin"] = ["lea", "leb"],
|
|
||||||
["Savannah"] = ["lea", "leb"],
|
|
||||||
["Scrubland"] = ["lea", "leb"],
|
|
||||||
["Serra Angel"] = ["lea", "leb"],
|
|
||||||
["Shivan Dragon"] = ["lea", "leb"],
|
|
||||||
["Sol Ring"] = ["lea", "leb"],
|
|
||||||
["Swords to Plowshares"] = ["lea", "leb"],
|
|
||||||
["Taiga"] = ["lea", "leb"],
|
|
||||||
["Time Walk"] = ["lea", "leb"],
|
|
||||||
["Timetwister"] = ["lea", "leb"],
|
|
||||||
["Tropical Island"] = ["lea", "leb"],
|
|
||||||
["Tundra"] = ["lea", "leb"],
|
|
||||||
["Underground Sea"] = ["lea", "leb"],
|
|
||||||
["Wheel of Fortune"] = ["lea", "leb"],
|
|
||||||
["Wrath of God"] = ["lea", "leb"],
|
|
||||||
|
|
||||||
// From reference/ - any set is fine
|
|
||||||
["Brainstorm"] = [],
|
|
||||||
["Force of Will"] = [],
|
|
||||||
["Griselbrand"] = [],
|
|
||||||
["Lotus Petal"] = [],
|
|
||||||
["Ponder"] = [],
|
|
||||||
["Show and Tell"] = [],
|
|
||||||
["Volcanic Island"] = [],
|
|
||||||
["Wasteland"] = [],
|
|
||||||
|
|
||||||
// From single_cards/ - any set is fine
|
|
||||||
["Adanto Vanguard"] = [],
|
|
||||||
["Angel of Sanctions"] = [],
|
|
||||||
["Attunement"] = [],
|
|
||||||
["Avaricious Dragon"] = [],
|
|
||||||
["Burgeoning"] = [],
|
|
||||||
["Jarad, Golgari Lich Lord"] = [],
|
|
||||||
["Meletis Charlatan"] = [],
|
|
||||||
["Mindstab Thrull"] = [],
|
|
||||||
["Pacifism"] = [],
|
|
||||||
["Platinum Angel"] = [],
|
|
||||||
["Queen Marchesa"] = [],
|
|
||||||
["Spellseeker"] = [],
|
|
||||||
["Tarmogoyf"] = [],
|
|
||||||
["Thought Reflection"] = [],
|
|
||||||
["Unsummon"] = [],
|
|
||||||
|
|
||||||
// From varying_quality - prefer older sets
|
|
||||||
["Dragon Whelp"] = ["lea", "leb"],
|
|
||||||
["Evil Eye of Orms-by-Gore"] = [],
|
|
||||||
["Instill Energy"] = ["lea", "leb"],
|
|
||||||
|
|
||||||
// Popular cards for general testing
|
|
||||||
["Lightning Helix"] = [],
|
|
||||||
["Path to Exile"] = [],
|
|
||||||
["Thoughtseize"] = [],
|
|
||||||
["Fatal Push"] = [],
|
|
||||||
["Snapcaster Mage"] = [],
|
|
||||||
["Jace, the Mind Sculptor"] = [],
|
|
||||||
["Liliana of the Veil"] = [],
|
|
||||||
["Noble Hierarch"] = [],
|
|
||||||
["Goblin Guide"] = [],
|
|
||||||
["Eidolon of the Great Revel"] = [],
|
|
||||||
};
|
|
||||||
|
|
||||||
var priorityCards = new HashSet<string>(priorityCardsWithSets.Keys, StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
// Force rebuild if requested
|
|
||||||
if (forceRebuild && File.Exists(outputDb))
|
|
||||||
{
|
|
||||||
Console.WriteLine("Force rebuild requested, removing existing database...");
|
|
||||||
File.Delete(outputDb);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var httpClient = new HttpClient();
|
|
||||||
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Scry/1.0 (MTG Card Scanner - Database Generator)");
|
|
||||||
|
|
||||||
using var scryfallClient = new ScryfallClient(httpClient);
|
|
||||||
using var db = new CardHashDatabase(outputDb);
|
|
||||||
|
|
||||||
// Check existing database state
|
|
||||||
var existingCardIds = await db.GetExistingCardIdsAsync();
|
|
||||||
var existingCardNames = await db.GetExistingCardNamesAsync();
|
|
||||||
var existingCount = await db.GetHashCountAsync();
|
|
||||||
var storedScryfallDate = await db.GetMetadataAsync("scryfall_updated_at");
|
|
||||||
|
|
||||||
Console.WriteLine($"Existing database has {existingCount} cards");
|
|
||||||
|
|
||||||
Console.WriteLine("Fetching bulk data info from Scryfall...");
|
|
||||||
var bulkInfo = await scryfallClient.GetBulkDataInfoAsync("unique_artwork");
|
|
||||||
|
|
||||||
if (bulkInfo?.DownloadUri == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("Failed to get bulk data info from Scryfall");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine($"Scryfall data last updated: {bulkInfo.UpdatedAt}");
|
|
||||||
|
|
||||||
// Check if we need to update at all
|
|
||||||
var scryfallDateStr = bulkInfo.UpdatedAt?.ToString("O") ?? "";
|
|
||||||
var needsUpdate = existingCount == 0 ||
|
|
||||||
storedScryfallDate != scryfallDateStr ||
|
|
||||||
existingCount < maxCards;
|
|
||||||
|
|
||||||
// Also check if all priority cards exist
|
|
||||||
var missingPriorityCards = includeTestCards
|
|
||||||
? priorityCards.Where(c => !existingCardNames.Contains(c)).ToList()
|
|
||||||
: new List<string>();
|
|
||||||
|
|
||||||
if (missingPriorityCards.Count > 0)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Missing {missingPriorityCards.Count} priority cards");
|
|
||||||
needsUpdate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!needsUpdate)
|
|
||||||
{
|
|
||||||
Console.WriteLine("Database is up-to-date, no changes needed");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine($"Downloading card data from: {bulkInfo.DownloadUri}");
|
|
||||||
Console.WriteLine();
|
|
||||||
|
|
||||||
var newHashes = new List<CardHash>();
|
|
||||||
var processed = 0;
|
|
||||||
var errors = 0;
|
|
||||||
var skipped = 0;
|
|
||||||
var priorityFound = 0;
|
|
||||||
var priorityNeeded = includeTestCards ? priorityCards.Count : 0;
|
|
||||||
|
|
||||||
// Track which priority cards we've already found with their set
|
|
||||||
// Key: card name, Value: set code
|
|
||||||
var foundPriorityWithSet = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
// Track deferred priority cards that might get a better set later
|
|
||||||
// Key: card name, Value: list of (cardHash, setCode) candidates
|
|
||||||
var deferredPriority = new Dictionary<string, List<(CardHash hash, string set)>>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
// Helper to check if a set is preferred for a priority card
|
|
||||||
bool IsPreferredSet(string cardName, string setCode)
|
|
||||||
{
|
|
||||||
if (!priorityCardsWithSets.TryGetValue(cardName, out var preferredSets))
|
|
||||||
return false;
|
|
||||||
return preferredSets.Length == 0 || preferredSets.Contains(setCode, StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
await foreach (var card in scryfallClient.StreamBulkDataAsync(bulkInfo.DownloadUri))
|
|
||||||
{
|
|
||||||
// Skip non-English cards
|
|
||||||
if (card.Lang != "en")
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var imageUri = card.GetImageUri("normal");
|
|
||||||
if (string.IsNullOrEmpty(imageUri))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var cardId = card.Id ?? Guid.NewGuid().ToString();
|
|
||||||
var cardName = card.Name ?? "Unknown";
|
|
||||||
var setCode = card.Set ?? "???";
|
|
||||||
|
|
||||||
// Check if this card already exists in the database
|
|
||||||
if (existingCardIds.Contains(cardId))
|
|
||||||
{
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a priority card we might need
|
|
||||||
var isPriorityCard = includeTestCards && priorityCards.Contains(cardName);
|
|
||||||
var isPreferred = isPriorityCard && IsPreferredSet(cardName, setCode);
|
|
||||||
|
|
||||||
// If this priority card already found with preferred set, skip
|
|
||||||
if (isPriorityCard && foundPriorityWithSet.TryGetValue(cardName, out var existingSet))
|
|
||||||
{
|
|
||||||
if (IsPreferredSet(cardName, existingSet))
|
|
||||||
{
|
|
||||||
// Already have preferred version
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// We have a non-preferred version; if this is preferred, we'll replace
|
|
||||||
if (!isPreferred)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate how many slots we have left
|
|
||||||
var totalCards = existingCount + newHashes.Count;
|
|
||||||
var priorityRemaining = priorityNeeded - foundPriorityWithSet.Count;
|
|
||||||
var slotsForNonPriority = maxCards - priorityRemaining;
|
|
||||||
|
|
||||||
// Skip if we have enough non-priority cards and this isn't priority
|
|
||||||
if (!isPriorityCard && totalCards >= slotsForNonPriority)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// Download and process image
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Console.Write($"[{processed + 1}] {cardName}... ");
|
|
||||||
|
|
||||||
var imageBytes = await httpClient.GetByteArrayAsync(imageUri);
|
|
||||||
using var bitmap = SKBitmap.Decode(imageBytes);
|
|
||||||
|
|
||||||
if (bitmap == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("decode failed");
|
|
||||||
errors++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply CLAHE preprocessing and compute hash
|
|
||||||
using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
|
|
||||||
var hash = PerceptualHash.ComputeColorHash(preprocessed);
|
|
||||||
|
|
||||||
var cardHash = new CardHash
|
|
||||||
{
|
|
||||||
CardId = cardId,
|
|
||||||
Name = cardName,
|
|
||||||
SetCode = setCode,
|
|
||||||
CollectorNumber = card.CollectorNumber,
|
|
||||||
Hash = hash,
|
|
||||||
ImageUri = imageUri
|
|
||||||
};
|
|
||||||
|
|
||||||
newHashes.Add(cardHash);
|
|
||||||
|
|
||||||
if (isPriorityCard)
|
|
||||||
{
|
|
||||||
foundPriorityWithSet[cardName] = setCode;
|
|
||||||
priorityFound++;
|
|
||||||
Console.WriteLine($"OK (priority, {setCode})");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine($"OK ({setCode})");
|
|
||||||
}
|
|
||||||
|
|
||||||
processed++;
|
|
||||||
|
|
||||||
// Check if we have enough cards
|
|
||||||
var foundAllPriority = foundPriorityWithSet.Count >= priorityNeeded;
|
|
||||||
if (existingCount + newHashes.Count >= maxCards && foundAllPriority)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"\nReached {maxCards} cards limit with all priority cards");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limit to be nice to Scryfall
|
|
||||||
await Task.Delay(50);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"error: {ex.Message}");
|
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.WriteLine($"Skipped (already in DB): {skipped}");
|
|
||||||
Console.WriteLine($"Newly processed: {processed} cards");
|
|
||||||
Console.WriteLine($"New priority cards found: {priorityFound}");
|
|
||||||
Console.WriteLine($"Total priority cards: {foundPriorityWithSet.Count}/{priorityNeeded}");
|
|
||||||
Console.WriteLine($"Errors: {errors}");
|
|
||||||
Console.WriteLine();
|
|
||||||
|
|
||||||
if (newHashes.Count > 0)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Inserting {newHashes.Count} new hashes into database...");
|
|
||||||
await db.InsertHashBatchAsync(newHashes);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.SetMetadataAsync("generated_at", DateTime.UtcNow.ToString("O"));
|
|
||||||
await db.SetMetadataAsync("scryfall_updated_at", scryfallDateStr);
|
|
||||||
|
|
||||||
var finalCount = await db.GetHashCountAsync();
|
|
||||||
await db.SetMetadataAsync("card_count", finalCount.ToString());
|
|
||||||
|
|
||||||
Console.WriteLine($"Database now has {finalCount} cards: {outputDb}");
|
|
||||||
|
|
||||||
// Report missing priority cards
|
|
||||||
if (includeTestCards)
|
|
||||||
{
|
|
||||||
var missing = priorityCards.Where(c => !foundPriorityWithSet.ContainsKey(c)).ToList();
|
|
||||||
|
|
||||||
if (missing.Count > 0)
|
|
||||||
{
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.WriteLine($"Missing priority cards ({missing.Count}):");
|
|
||||||
foreach (var name in missing.Take(20))
|
|
||||||
{
|
|
||||||
Console.WriteLine($" - {name}");
|
|
||||||
}
|
|
||||||
if (missing.Count > 20)
|
|
||||||
{
|
|
||||||
Console.WriteLine($" ... and {missing.Count - 20} more");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue