Iniital release of DosVault.
This commit is contained in:
62
.gitignore
vendored
Normal file
62
.gitignore
vendored
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Development
|
||||||
|
# devenv.lock
|
||||||
|
# devenv.nix
|
||||||
|
.direnv/
|
||||||
|
.devenv/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Local data
|
||||||
|
src/roms.db*
|
||||||
|
roms/
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
images/
|
||||||
|
|
||||||
|
# Release builds
|
||||||
|
release/
|
||||||
100
CLAUDE.md
Normal file
100
CLAUDE.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
The project uses devenv.nix for development environment management. All commands should be run from the repository root:
|
||||||
|
|
||||||
|
- `tests` - Run pytest with coverage
|
||||||
|
- `lint` - Check code with ruff and black
|
||||||
|
- `fix` - Auto-fix code issues with ruff and black
|
||||||
|
- `typecheck` - Run pyright type checking
|
||||||
|
- `run` - Execute the main ROM scraper application
|
||||||
|
- `serve` - Start the FastAPI web server
|
||||||
|
- `create-admin` - Create initial admin user for web interface
|
||||||
|
- `migrate` - Database migration management (see Migration Commands)
|
||||||
|
- `db-init` - Initialize database schema (first-time setup)
|
||||||
|
- `db-upgrade` - Apply pending database migrations
|
||||||
|
- `db-create` - Create new migration (requires message argument)
|
||||||
|
- `build` - Build the application using build.sh (creates zipapp in release/)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This is a Python ROM metadata scraper and web-based ROM management system for DOS games:
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
- **Main Application** (`src/__main__.py`): Async scraper that scans ROM directories, fetches metadata from IGDB API, and stores everything in SQLite
|
||||||
|
- **Web Application** (`src/webapp.py`): FastAPI server with user authentication, ROM browsing, downloads, and admin interface
|
||||||
|
- **Configuration** (`src/libs/config.py`): XDG-compliant config management with automatic setup prompts
|
||||||
|
- **Database Layer** (`src/libs/database.py`): SQLAlchemy models with many-to-many relationships for games, metadata, genres, tags, and users
|
||||||
|
- **Authentication** (`src/libs/auth.py`): JWT-based auth with bcrypt password hashing and role-based access control
|
||||||
|
- **Data Models** (`src/libs/objects.py`): Dataclasses for Game, Metadata, and Roms collections
|
||||||
|
- **API Integration** (`src/libs/apis.py`): IGDB API client with Twitch OAuth authentication
|
||||||
|
- **Utilities** (`src/libs/functions.py`): Title cleaning and year extraction from ROM filenames
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
**ROM Scraping:**
|
||||||
|
1. Compares filesystem ROMs with database entries to avoid re-indexing
|
||||||
|
2. Authenticates with IGDB via Twitch OAuth using client credentials
|
||||||
|
3. Scrapes metadata for new games only with rate limiting (4 concurrent requests)
|
||||||
|
4. Stores normalized data in SQLite with proper foreign key relationships
|
||||||
|
5. Handles duplicate games and metadata updates gracefully
|
||||||
|
|
||||||
|
**Web Interface:**
|
||||||
|
1. FastAPI serves modern responsive web interface with Tailwind CSS
|
||||||
|
2. JWT-based authentication with three user roles: demo, normal, super
|
||||||
|
3. Demo users can browse but not download; normal users get full access; super users can manage everything
|
||||||
|
4. Pagination, favorites system, and file downloads for authorized users
|
||||||
|
5. Admin interface for user management and metadata editing
|
||||||
|
|
||||||
|
### Key Technical Details
|
||||||
|
|
||||||
|
- Uses asyncio with semaphore-based rate limiting for API requests
|
||||||
|
- SQLAlchemy with declarative base and proper naming conventions
|
||||||
|
- FastAPI with Jinja2 templates, JWT authentication, and role-based access control
|
||||||
|
- Configuration supports both environment variables and .env files
|
||||||
|
- Custom PathType for storing pathlib.Path objects in database
|
||||||
|
- Batch processing for database operations with configurable batch sizes
|
||||||
|
- Modern responsive UI with Tailwind CSS and Alpine.js for interactivity
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
The project uses Alembic for database schema versioning and migrations:
|
||||||
|
|
||||||
|
### First-Time Setup
|
||||||
|
```bash
|
||||||
|
db-init # Initialize database with current schema
|
||||||
|
migrate stamp # Mark database as up-to-date with migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Management
|
||||||
|
```bash
|
||||||
|
migrate create "description" # Create new migration file
|
||||||
|
migrate upgrade # Apply all pending migrations
|
||||||
|
migrate current # Show current database revision
|
||||||
|
migrate history # Show migration history
|
||||||
|
migrate check # Check database migration status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema Changes
|
||||||
|
1. Modify models in `src/libs/database.py`
|
||||||
|
2. Create migration: `migrate create "description of changes"`
|
||||||
|
3. Review generated migration file in `migrations/versions/`
|
||||||
|
4. Apply migration: `migrate upgrade`
|
||||||
|
|
||||||
|
### Migration Files
|
||||||
|
- Located in `migrations/versions/`
|
||||||
|
- Named with revision ID and description
|
||||||
|
- Contain `upgrade()` and `downgrade()` functions
|
||||||
|
- Support batch operations for SQLite compatibility
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
Requires IGDB API credentials:
|
||||||
|
- `IGDB_CLIENT_ID` - Twitch client ID
|
||||||
|
- `IGDB_SECRET_KEY` - Twitch client secret
|
||||||
|
|
||||||
|
Can be provided via environment variables or `.env` file in project root.
|
||||||
109
DOCKER.md
Normal file
109
DOCKER.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# DosVault Docker Deployment
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. **Copy the environment template:**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Edit `.env` with your configuration:**
|
||||||
|
- Set `IGDB_CLIENT_ID` and `IGDB_SECRET_KEY` (required)
|
||||||
|
- Set `ROMS_PATH` to your ROM collection directory
|
||||||
|
- Optionally customize host/port settings
|
||||||
|
|
||||||
|
3. **Start the application:**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Create admin user:**
|
||||||
|
```bash
|
||||||
|
docker-compose exec dosvault python src/create_admin.py
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Access the application:**
|
||||||
|
- Web interface: http://localhost:8080
|
||||||
|
- Admin panel: http://localhost:8080/admin
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `IGDB_CLIENT_ID` | Yes | Twitch API Client ID |
|
||||||
|
| `IGDB_SECRET_KEY` | Yes | Twitch API Client Secret |
|
||||||
|
| `ROMS_PATH` | No | Path to ROM collection (default: ./roms) |
|
||||||
|
| `DOSFRONTEND_CONFIG_DIR` | No | Application data directory (default: /app/data) |
|
||||||
|
|
||||||
|
### Configuration Persistence
|
||||||
|
|
||||||
|
Configuration changes made through the web interface are automatically persisted to the mounted volume:
|
||||||
|
|
||||||
|
- **In Docker**: Configuration is stored in `/app/data/config.json` (mounted volume)
|
||||||
|
- **Regular install**: Configuration is stored in `~/.config/dosfrontend/config.json`
|
||||||
|
- **File structure**: All application data uses the same base directory:
|
||||||
|
- `config.json` - Main configuration file
|
||||||
|
- `roms.db` - SQLite database
|
||||||
|
- `images/` - Downloaded game artwork
|
||||||
|
- `logs/` - Application logs
|
||||||
|
|
||||||
|
### Volume Mounts
|
||||||
|
|
||||||
|
- `dosvault_data:/app/data` - Application data (database, images, logs)
|
||||||
|
- `${ROMS_PATH}:/app/data/roms:ro` - ROM collection (read-only)
|
||||||
|
|
||||||
|
## Database Management
|
||||||
|
|
||||||
|
### Initialize Database
|
||||||
|
```bash
|
||||||
|
docker-compose exec dosvault python src/migrate.py db-init
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Migrations
|
||||||
|
```bash
|
||||||
|
docker-compose exec dosvault python src/migrate.py upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scrape ROM Metadata
|
||||||
|
```bash
|
||||||
|
docker-compose exec dosvault python -m src
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f dosvault
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup Database
|
||||||
|
```bash
|
||||||
|
docker-compose exec dosvault cp /app/data/roms.db /app/data/backup.db
|
||||||
|
docker cp $(docker-compose ps -q dosvault):/app/data/backup.db ./backup.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Application
|
||||||
|
```bash
|
||||||
|
docker-compose pull
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Check Container Health
|
||||||
|
```bash
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access Container Shell
|
||||||
|
```bash
|
||||||
|
docker-compose exec dosvault bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset Data
|
||||||
|
```bash
|
||||||
|
docker-compose down -v
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Multi-stage Docker build for DosVault
|
||||||
|
FROM python:3.11-slim as base
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy Python dependencies
|
||||||
|
COPY requirements.txt* pyproject.toml* ./
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt || \
|
||||||
|
pip install --no-cache-dir fastapi uvicorn sqlalchemy alembic \
|
||||||
|
aiohttp bcrypt python-jose python-multipart jinja2
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY templates/ ./templates/
|
||||||
|
COPY migrations/ ./migrations/
|
||||||
|
COPY alembic.ini ./
|
||||||
|
COPY CLAUDE.md README.md ./
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
RUN mkdir -p /app/data/logs /app/data/images /app/data/roms /app/data/metadata
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV DOSFRONTEND_CONFIG_DIR=/app/data
|
||||||
|
|
||||||
|
# Expose ports
|
||||||
|
EXPOSE 8080 8081
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -u 1000 dosvault && \
|
||||||
|
chown -R dosvault:dosvault /app
|
||||||
|
USER dosvault
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
# Default command
|
||||||
|
CMD ["python", "-m", "uvicorn", "src.webapp:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
197
README.md
Normal file
197
README.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# 🎮 DosVault
|
||||||
|
|
||||||
|
**Your Personal DOS Game Collection Manager**
|
||||||
|
|
||||||
|
DosVault is a modern, web-based collection manager for DOS games that combines powerful metadata scraping with an intuitive browsing experience. Built with Python and FastAPI, it helps you organize, discover, and manage your retro gaming library with style.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
### 🎯 Core Functionality
|
||||||
|
- **Automatic Metadata Scraping** - Pulls game information, cover art, and screenshots from IGDB API
|
||||||
|
- **Local Image Storage** - Downloads and caches all images locally for fast loading
|
||||||
|
- **Intelligent ROM Detection** - Scans directories and avoids re-indexing existing games
|
||||||
|
- **Advanced Search & Filtering** - Find games by title, genre, developer, or description
|
||||||
|
- **Genre & Tag Browsing** - Organized categorization with alphabetical sorting
|
||||||
|
|
||||||
|
### 🌐 Modern Web Interface
|
||||||
|
- **Responsive Design** - Works beautifully on desktop, tablet, and mobile
|
||||||
|
- **Multiple View Modes** - Switch between grid and list views
|
||||||
|
- **Interactive Screenshots** - Click to view full-screen image galleries
|
||||||
|
- **Smart Pagination** - Navigate large collections with ease
|
||||||
|
- **Real-time Favorites** - Heart games to build your personal collection
|
||||||
|
|
||||||
|
### 🔐 User Management
|
||||||
|
- **Role-Based Access Control** - Demo, Normal, and Super Admin roles
|
||||||
|
- **Secure Authentication** - JWT-based auth with bcrypt password hashing
|
||||||
|
- **Personal Favorites** - Each user maintains their own favorites list
|
||||||
|
- **Admin Dashboard** - User management and system overview
|
||||||
|
|
||||||
|
### 📱 Mobile-First
|
||||||
|
- **Hamburger Navigation** - Clean mobile menu system
|
||||||
|
- **Touch-Optimized** - Large buttons and smooth interactions
|
||||||
|
- **Responsive Controls** - Pagination and filters work great on mobile
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Python 3.11+
|
||||||
|
- [Devenv](https://devenv.sh/) (recommended) or manual dependency management
|
||||||
|
- IGDB API credentials (free from Twitch Developer Console)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. **Clone the repository:**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd dosfrontend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Set up environment:**
|
||||||
|
```bash
|
||||||
|
# With devenv (recommended)
|
||||||
|
devenv shell
|
||||||
|
|
||||||
|
# Or manually install dependencies
|
||||||
|
pip install fastapi uvicorn sqlalchemy alembic bcrypt python-jose aiohttp
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure IGDB API:**
|
||||||
|
Create a `.env` file with your IGDB credentials:
|
||||||
|
```env
|
||||||
|
IGDB_CLIENT_ID=your_twitch_client_id
|
||||||
|
IGDB_SECRET_KEY=your_twitch_client_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Initialize database:**
|
||||||
|
```bash
|
||||||
|
db-init
|
||||||
|
create-admin # Create your first admin user
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run the application:**
|
||||||
|
```bash
|
||||||
|
serve # Starts web server
|
||||||
|
run # Runs ROM scraper (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Access DosVault:**
|
||||||
|
Open http://localhost:8080 in your browser
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
dosfrontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── __main__.py # ROM scraper application
|
||||||
|
│ ├── webapp.py # FastAPI web server
|
||||||
|
│ └── libs/
|
||||||
|
│ ├── config.py # XDG-compliant configuration
|
||||||
|
│ ├── database.py # SQLAlchemy models
|
||||||
|
│ ├── auth.py # JWT authentication
|
||||||
|
│ ├── apis.py # IGDB API integration
|
||||||
|
│ └── functions.py # Utility functions
|
||||||
|
├── templates/ # Jinja2 HTML templates
|
||||||
|
├── migrations/ # Database schema versions
|
||||||
|
├── devenv.nix # Development environment
|
||||||
|
└── CLAUDE.md # Development guidance
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎮 Usage
|
||||||
|
|
||||||
|
### Scraping ROMs
|
||||||
|
```bash
|
||||||
|
# Scan ROM directories and fetch metadata
|
||||||
|
run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Interface
|
||||||
|
```bash
|
||||||
|
# Start the web server
|
||||||
|
serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Management
|
||||||
|
```bash
|
||||||
|
# Create migrations
|
||||||
|
migrate create "description of changes"
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
migrate upgrade
|
||||||
|
|
||||||
|
# Check migration status
|
||||||
|
migrate current
|
||||||
|
```
|
||||||
|
|
||||||
|
### Administration
|
||||||
|
```bash
|
||||||
|
# Create admin user
|
||||||
|
create-admin
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
tests
|
||||||
|
|
||||||
|
# Code quality
|
||||||
|
lint
|
||||||
|
typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
DosVault uses XDG-compliant configuration stored in:
|
||||||
|
- **Linux/Mac:** `~/.config/dosfrontend/`
|
||||||
|
- **Windows:** `%APPDATA%/dosfrontend/`
|
||||||
|
|
||||||
|
Key configuration options:
|
||||||
|
- ROM directories to scan
|
||||||
|
- Image storage location
|
||||||
|
- Database path
|
||||||
|
- Web server host/port
|
||||||
|
- IGDB API credentials
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **FastAPI** - Modern Python web framework
|
||||||
|
- **SQLAlchemy** - Database ORM with proper relationships
|
||||||
|
- **Alembic** - Database migration management
|
||||||
|
- **AsyncIO** - Concurrent API requests with rate limiting
|
||||||
|
- **JWT + BCrypt** - Secure authentication
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Jinja2** - Server-side templating
|
||||||
|
- **Tailwind CSS** - Utility-first styling
|
||||||
|
- **Alpine.js** - Lightweight JavaScript framework
|
||||||
|
- **Responsive Design** - Mobile-first approach
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
1. **Scraper** scans ROM directories and compares with database
|
||||||
|
2. **IGDB API** provides metadata via Twitch OAuth
|
||||||
|
3. **Images** are downloaded and cached locally
|
||||||
|
4. **Web interface** serves games with fast local assets
|
||||||
|
5. **Users** browse, search, and manage favorites
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Make your changes
|
||||||
|
4. Run tests and linting (`tests`, `lint`)
|
||||||
|
5. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||||
|
6. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
7. Open a Pull Request
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## 🙏 Acknowledgments
|
||||||
|
|
||||||
|
- **IGDB** for providing comprehensive game metadata
|
||||||
|
- **Twitch** for OAuth authentication to IGDB API
|
||||||
|
- **FastAPI** for the excellent modern Python web framework
|
||||||
|
- **Tailwind CSS** for making responsive design a breeze
|
||||||
|
- **DOSBox** community for keeping retro gaming alive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built with ❤️ for retro gaming enthusiasts**
|
||||||
94
alembic.ini
Normal file
94
alembic.ini
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = migrations
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python-dateutil library that can be
|
||||||
|
# installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to dateutil.tz.gettz()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the
|
||||||
|
# "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version number format to use with the --rev-id parameter
|
||||||
|
# to specify a starting revision
|
||||||
|
# version_num_format = %04d
|
||||||
|
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = os # Use os.pathsep. Default configuration used on new projects.
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
2
build.sh
Executable file
2
build.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
python -m zipapp src --compress --output=release/dfe --python="/usr/bin/env python"
|
||||||
103
devenv.lock
Normal file
103
devenv.lock
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"devenv": {
|
||||||
|
"locked": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"lastModified": 1756415044,
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"rev": "c570189b38b549141179647da3ddde249ac50fec",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1747046372,
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"gitignore": "gitignore",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1755960406,
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"rev": "e891a93b193fcaf2fc8012d890dc7f0befe86ec2",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitignore": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"git-hooks",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709087332,
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1755783167,
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"rev": "4a880fb247d24fbca57269af672e8f78935b0328",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "rolling",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": "devenv",
|
||||||
|
"git-hooks": "git-hooks",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"pre-commit-hooks": [
|
||||||
|
"git-hooks"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
97
devenv.nix
Normal file
97
devenv.nix
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{ pkgs, lib, config, inputs, ... }:
|
||||||
|
|
||||||
|
{
|
||||||
|
# https://devenv.sh/basics/
|
||||||
|
|
||||||
|
# https://devenv.sh/packages/
|
||||||
|
packages = with pkgs; [
|
||||||
|
git
|
||||||
|
curl
|
||||||
|
pkg-config
|
||||||
|
sqlite
|
||||||
|
pyright
|
||||||
|
pre-commit
|
||||||
|
];
|
||||||
|
languages.python = {
|
||||||
|
enable = true;
|
||||||
|
package = pkgs.python313;
|
||||||
|
libraries = with pkgs.python313Packages; [ ];
|
||||||
|
venv = {
|
||||||
|
enable = true;
|
||||||
|
requirements = ''
|
||||||
|
pudb
|
||||||
|
ptpython
|
||||||
|
ipython
|
||||||
|
pytest
|
||||||
|
pytest-cov
|
||||||
|
flake8
|
||||||
|
ptpython
|
||||||
|
ipython
|
||||||
|
isort
|
||||||
|
pynvim
|
||||||
|
ruff
|
||||||
|
black
|
||||||
|
sqlalchemy
|
||||||
|
requests
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
jinja2
|
||||||
|
python-multipart
|
||||||
|
bcrypt
|
||||||
|
python-jose
|
||||||
|
passlib
|
||||||
|
alembic
|
||||||
|
aiohttp
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
# uv = {
|
||||||
|
# enable = false;
|
||||||
|
# sync.enable = true;
|
||||||
|
# };
|
||||||
|
};
|
||||||
|
env = {
|
||||||
|
PYTHONBREAKPOINT = "pudb.set_trace";
|
||||||
|
};
|
||||||
|
|
||||||
|
# https://devenv.sh/variables/
|
||||||
|
# variables = {
|
||||||
|
# GREET = "world";
|
||||||
|
# };
|
||||||
|
|
||||||
|
# https://devenv.sh/scripts/
|
||||||
|
scripts = {
|
||||||
|
"tests".exec = "cd $REPO_ROOT && python -m pytest --rootdir=$REPO_ROOT -c $REPO_ROOT/pytest.ini";
|
||||||
|
"lint".exec = "cd $REPO_ROOT && ${pkgs.ruff}/bin/ruff check . && black --check .";
|
||||||
|
"fix".exec = "cd $REPO_ROOT && ${pkgs.ruff}/bin/ruff check . --fix && black .";
|
||||||
|
"typecheck".exec = "cd $REPO_ROOT && pyright";
|
||||||
|
"run".exec = ''cd $REPO_ROOT && ./src/__main__.py "$@"'';
|
||||||
|
"serve".exec = "cd $REPO_ROOT && python src/webapp.py";
|
||||||
|
"create-admin".exec = "cd $REPO_ROOT && python src/create_admin.py";
|
||||||
|
"migrate".exec = "cd $REPO_ROOT && python src/migrate.py";
|
||||||
|
"db-init".exec = "cd $REPO_ROOT && python src/migrate.py init";
|
||||||
|
"db-upgrade".exec = "cd $REPO_ROOT && python src/migrate.py upgrade";
|
||||||
|
"db-create".exec = "cd $REPO_ROOT && python src/migrate.py create";
|
||||||
|
"build".exec = "cd $REPO_ROOT && ./build.sh";
|
||||||
|
"backfill-images".exec = "cd $REPO_ROOT && python src/backfill_images.py";
|
||||||
|
};
|
||||||
|
enterShell = ''
|
||||||
|
export REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||||
|
'';
|
||||||
|
|
||||||
|
# https://devenv.sh/tasks/
|
||||||
|
# tasks = {
|
||||||
|
# "myproj:setup".exec = "mytool build";
|
||||||
|
# "devenv:enterShell".after = [ "myproj:setup" ];
|
||||||
|
# };
|
||||||
|
|
||||||
|
# https://devenv.sh/tests/
|
||||||
|
enterTest = ''
|
||||||
|
echo "Running tests"
|
||||||
|
pytest -q
|
||||||
|
'';
|
||||||
|
|
||||||
|
# https://devenv.sh/git-hooks/
|
||||||
|
# git-hooks.hooks.shellcheck.enable = true;
|
||||||
|
|
||||||
|
# See full reference at https://devenv.sh/reference/options/
|
||||||
|
}
|
||||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
dosvault:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "8081:8081"
|
||||||
|
volumes:
|
||||||
|
# Mount data directory for persistence
|
||||||
|
- dosvault_data:/app/data
|
||||||
|
# Mount ROM directory (customize this path)
|
||||||
|
- "${ROMS_PATH:-./roms}:/app/data/roms:ro"
|
||||||
|
environment:
|
||||||
|
# IGDB API Configuration
|
||||||
|
- IGDB_CLIENT_ID=${IGDB_CLIENT_ID}
|
||||||
|
- IGDB_SECRET_KEY=${IGDB_SECRET_KEY}
|
||||||
|
# Application Configuration
|
||||||
|
- DOSFRONTEND_CONFIG_DIR=/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dosvault_data:
|
||||||
|
driver: local
|
||||||
92
migrations/env.py
Normal file
92
migrations/env.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add src to Python path
|
||||||
|
src_path = Path(__file__).parent.parent / "src"
|
||||||
|
sys.path.insert(0, str(src_path))
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Import your models
|
||||||
|
from libs.database import Base
|
||||||
|
from libs.config import Config
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# Set the SQLAlchemy URL from our config
|
||||||
|
app_config = Config()
|
||||||
|
config.set_main_option("sqlalchemy.url", f"sqlite:///{app_config.database_path}")
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
render_as_batch=True, # Enable batch mode for SQLite
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
render_as_batch=True, # Enable batch mode for SQLite
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
106
migrations/versions/001_initial_schema.py
Normal file
106
migrations/versions/001_initial_schema.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Initial database schema
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-01-01 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.types import TypeDecorator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '001'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Define the PathType here since it's needed for the migration
|
||||||
|
class PathType(TypeDecorator):
|
||||||
|
impl = sa.String
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
return None if value is None else str(value)
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return None if value is None else Path(value)
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# This represents the initial schema from the original system
|
||||||
|
# The tables (tags, genre, game, metadata, metadata_genres, metadata_tags)
|
||||||
|
# already exist in the database, so this migration is just for tracking
|
||||||
|
|
||||||
|
# If running on a fresh database, these would create the tables:
|
||||||
|
|
||||||
|
# Create tags table
|
||||||
|
op.create_table('tags',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=30), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_tags'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_tags_name', 'tags', ['name'], unique=True)
|
||||||
|
|
||||||
|
# Create genre table
|
||||||
|
op.create_table('genre',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=30), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_genre'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_genre_name', 'genre', ['name'], unique=True)
|
||||||
|
|
||||||
|
# Create game table
|
||||||
|
op.create_table('game',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=66), nullable=False),
|
||||||
|
sa.Column('path', PathType(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_game')),
|
||||||
|
sa.UniqueConstraint('path', name=op.f('uq_game_path'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_game_title', 'game', ['title'])
|
||||||
|
|
||||||
|
# Create metadata table
|
||||||
|
op.create_table('metadata',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('game_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=66), nullable=False),
|
||||||
|
sa.Column('description', sa.String(), nullable=True),
|
||||||
|
sa.Column('year', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('developer', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('publisher', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('players', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('cover_image', sa.String(), nullable=True),
|
||||||
|
sa.Column('screenshot', sa.String(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['game_id'], ['game.id'], name=op.f('fk_metadata_game_id_game'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_metadata')),
|
||||||
|
sa.UniqueConstraint('game_id', name=op.f('uq_metadata_game_id'))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create association tables
|
||||||
|
op.create_table('metadata_genres',
|
||||||
|
sa.Column('metadata_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('genre_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['genre_id'], ['genre.id'], name=op.f('fk_metadata_genres_genre_id_genre'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['metadata_id'], ['metadata.id'], name=op.f('fk_metadata_genres_metadata_id_metadata'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('metadata_id', 'genre_id', name=op.f('pk_metadata_genres')),
|
||||||
|
sa.UniqueConstraint('metadata_id', 'genre_id', name=op.f('uq_metadata_genres_metadata_id'))
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table('metadata_tags',
|
||||||
|
sa.Column('metadata_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('tag_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['metadata_id'], ['metadata.id'], name=op.f('fk_metadata_tags_metadata_id_metadata'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], name=op.f('fk_metadata_tags_tag_id_tags'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('metadata_id', 'tag_id', name=op.f('pk_metadata_tags')),
|
||||||
|
sa.UniqueConstraint('metadata_id', 'tag_id', name=op.f('uq_metadata_tags_metadata_id'))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('metadata_tags')
|
||||||
|
op.drop_table('metadata_genres')
|
||||||
|
op.drop_table('metadata')
|
||||||
|
op.drop_table('game')
|
||||||
|
op.drop_table('genre')
|
||||||
|
op.drop_table('tags')
|
||||||
47
migrations/versions/002_add_user_system.py
Normal file
47
migrations/versions/002_add_user_system.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Add user authentication system
|
||||||
|
|
||||||
|
Revision ID: 002
|
||||||
|
Revises: 001
|
||||||
|
Create Date: 2024-01-01 11:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '002'
|
||||||
|
down_revision = '001'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create users table
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('role', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('last_login', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_users_email', 'users', ['email'], unique=True)
|
||||||
|
op.create_index('ix_users_username', 'users', ['username'], unique=True)
|
||||||
|
|
||||||
|
# Create user_favorites association table
|
||||||
|
op.create_table('user_favorites',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('game_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['game_id'], ['game.id'], name=op.f('fk_user_favorites_game_id_game'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_favorites_user_id_users'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('user_id', 'game_id', name=op.f('pk_user_favorites')),
|
||||||
|
sa.UniqueConstraint('user_id', 'game_id', name=op.f('uq_user_favorites_user_id'))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('user_favorites')
|
||||||
|
op.drop_table('users')
|
||||||
33
migrations/versions/002_example_migration.py.example
Normal file
33
migrations/versions/002_example_migration.py.example
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Example migration - add rating column to metadata
|
||||||
|
|
||||||
|
This is an example of how to create a migration.
|
||||||
|
To use this:
|
||||||
|
1. Remove the .example extension
|
||||||
|
2. Update the revision ID and down_revision
|
||||||
|
3. Run: migrate upgrade
|
||||||
|
|
||||||
|
Revision ID: 002
|
||||||
|
Revises: 001
|
||||||
|
Create Date: 2024-01-01 11:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '002'
|
||||||
|
down_revision = '001'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add a rating column to the metadata table
|
||||||
|
with op.batch_alter_table('metadata', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('rating', sa.Float(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove the rating column from the metadata table
|
||||||
|
with op.batch_alter_table('metadata', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('rating')
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""add local image path fields
|
||||||
|
|
||||||
|
Revision ID: 3e8f92662c04
|
||||||
|
Revises: 002
|
||||||
|
Create Date: 2025-09-06 01:18:21.497321
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent.parent / 'src'))
|
||||||
|
from libs.database import PathType
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '3e8f92662c04'
|
||||||
|
down_revision = '002'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('metadata', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('cover_image_path', PathType(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('screenshot_path', PathType(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('metadata', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('screenshot_path')
|
||||||
|
batch_op.drop_column('cover_image_path')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
3
pytest.ini
Normal file
3
pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
addopts = --cov=src --cov-report=term-missing --ignore=src/__main__.py
|
||||||
|
testpaths = tests/
|
||||||
21
requirements.txt
Normal file
21
requirements.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# DosVault Python Dependencies
|
||||||
|
|
||||||
|
# Web Framework
|
||||||
|
fastapi>=0.104.0
|
||||||
|
uvicorn[standard]>=0.24.0
|
||||||
|
jinja2>=3.1.2
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
alembic>=1.12.0
|
||||||
|
|
||||||
|
# Authentication & Security
|
||||||
|
python-jose[cryptography]>=3.3.0
|
||||||
|
bcrypt>=4.0.1
|
||||||
|
|
||||||
|
# HTTP Client
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
pathlib2>=2.3.7; python_version<"3.4"
|
||||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
159
src/__main__.py
Executable file
159
src/__main__.py
Executable file
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from libs.config import Config
|
||||||
|
from libs.database import (Base, ingest_roms, get_existing_rom_paths)
|
||||||
|
from libs.objects import Metadata, Game, Roms
|
||||||
|
from libs.functions import extract_year_from_title, clean_title, download_image, get_image_filename
|
||||||
|
from libs.apis import Credentials, IGDB
|
||||||
|
from libs.logging import get_log_manager
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
token = Credentials(config).authenticate()
|
||||||
|
scrape_errors: List[str] = []
|
||||||
|
|
||||||
|
async def scrape_metadata(title: str, session: aiohttp.ClientSession) -> Metadata:
|
||||||
|
igdb_response = IGDB(token)
|
||||||
|
md = Metadata()
|
||||||
|
igdb_response = IGDB(token).search_game_by_title(clean_title(title))
|
||||||
|
try:
|
||||||
|
if igdb_response[0]:
|
||||||
|
game_data = igdb_response[0]
|
||||||
|
try: md.title = game_data.get('name', title)
|
||||||
|
except: md.title = title
|
||||||
|
try: md.description = game_data.get('summary')
|
||||||
|
except: md.description = None
|
||||||
|
try: md.year = game_data.get('first_release_date')
|
||||||
|
except: md.year = extract_year_from_title(title)
|
||||||
|
try: md.developer = game_data.get('involved_companies', "")[0].get('company', "").get('name')
|
||||||
|
except: md.developer = None
|
||||||
|
try: md.publisher = game_data.get('involved_companies', "")[0].get('company', "").get('name')
|
||||||
|
except: md.publisher = None
|
||||||
|
try: md.genre = [genre['name'] for genre in game_data.get('genres', [])]
|
||||||
|
except: md.genre = []
|
||||||
|
try: md.players = game_data.get('player_perspectives', 1)[0]
|
||||||
|
except: md.players = 1
|
||||||
|
try:
|
||||||
|
cover_data = game_data.get('cover')
|
||||||
|
if cover_data and cover_data.get('image_id'):
|
||||||
|
md.cover_image = IGDB.build_cover_url(cover_data['image_id'], 'cover_big')
|
||||||
|
|
||||||
|
# Download cover image locally
|
||||||
|
cover_filename = get_image_filename(md.cover_image, title, 'cover')
|
||||||
|
cover_path = config.images_path / cover_filename
|
||||||
|
if await download_image(md.cover_image, cover_path, session):
|
||||||
|
md.cover_image_path = cover_path
|
||||||
|
else:
|
||||||
|
md.cover_image = None
|
||||||
|
md.cover_image_path = None
|
||||||
|
except:
|
||||||
|
md.cover_image = None
|
||||||
|
md.cover_image_path = None
|
||||||
|
try:
|
||||||
|
artworks = game_data.get('artworks', [])
|
||||||
|
if artworks and artworks[0].get('image_id'):
|
||||||
|
md.screenshot = IGDB.build_cover_url(artworks[0]['image_id'], 'screenshot_med')
|
||||||
|
|
||||||
|
# Download screenshot locally
|
||||||
|
screenshot_filename = get_image_filename(md.screenshot, title, 'screenshot')
|
||||||
|
screenshot_path = config.images_path / screenshot_filename
|
||||||
|
if await download_image(md.screenshot, screenshot_path, session):
|
||||||
|
md.screenshot_path = screenshot_path
|
||||||
|
else:
|
||||||
|
md.screenshot = None
|
||||||
|
md.screenshot_path = None
|
||||||
|
except:
|
||||||
|
md.screenshot = None
|
||||||
|
md.screenshot_path = None
|
||||||
|
try: md.tags = [theme['name'] for theme in game_data.get('themes', [])]
|
||||||
|
except: md.tags = []
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
return md
|
||||||
|
|
||||||
|
async def make_romlist(dir: Optional[Path] = None, roms: Optional[Roms] = None) -> Roms:
|
||||||
|
romList: Roms = roms if roms else Roms()
|
||||||
|
rompath: Path = dir if dir else config.rom_path
|
||||||
|
|
||||||
|
for pointer in rompath.rglob("*"):
|
||||||
|
if pointer.is_file():
|
||||||
|
title = pointer.stem
|
||||||
|
romList.list.append(Game(title=title, path=pointer, metadata=Metadata()))
|
||||||
|
return romList
|
||||||
|
|
||||||
|
|
||||||
|
async def inject_metadata(roms: Roms) -> Roms:
|
||||||
|
sem = asyncio.Semaphore(4) # run up to 4 concurrent scrapes
|
||||||
|
results = [None] * len(roms.list)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async def _job(i: int, game):
|
||||||
|
async with sem:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0.25) # keep your throttle
|
||||||
|
md = await scrape_metadata(game.title, session)
|
||||||
|
except ValueError:
|
||||||
|
scrape_errors.append(game.title)
|
||||||
|
md = Metadata(title=game.title, year=extract_year_from_title(game.title))
|
||||||
|
# print each item as its done to the top of the screen
|
||||||
|
results[i] = md
|
||||||
|
print("\033[F\033[K", end='')
|
||||||
|
for err in scrape_errors[-5:]:
|
||||||
|
print(f"Error: {err}")
|
||||||
|
print(f"Scraped: {game.title} # {i+1}/{len(roms.list)}")
|
||||||
|
|
||||||
|
tasks = [asyncio.create_task(_job(i, game)) for i, game in enumerate(roms.list)]
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
for game, md in zip(roms.list, results):
|
||||||
|
game.metadata = md
|
||||||
|
|
||||||
|
return roms
|
||||||
|
|
||||||
|
async def filter_new_roms(romlist: Roms, session: Session) -> Roms:
|
||||||
|
existing_paths = get_existing_rom_paths(session)
|
||||||
|
new_roms = Roms()
|
||||||
|
|
||||||
|
for game in romlist.list:
|
||||||
|
if game.path.resolve() not in existing_paths:
|
||||||
|
new_roms.list.append(game)
|
||||||
|
|
||||||
|
print(f"Found {len(romlist.list)} total ROMs")
|
||||||
|
print(f"Found {len(existing_paths)} existing ROMs in database")
|
||||||
|
print(f"Will scrape {len(new_roms.list)} new ROMs")
|
||||||
|
|
||||||
|
return new_roms
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
url = f"sqlite+pysqlite:///{config.database_path}"
|
||||||
|
engine = create_engine(url, future=True)
|
||||||
|
# Database tables are now managed by migrations
|
||||||
|
# Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
romlist = await make_romlist()
|
||||||
|
new_romlist = await filter_new_roms(romlist, s)
|
||||||
|
|
||||||
|
if new_romlist.list:
|
||||||
|
new_romlist = await inject_metadata(new_romlist)
|
||||||
|
ingest_roms(new_romlist, s)
|
||||||
|
else:
|
||||||
|
print("No new ROMs to scrape!")
|
||||||
|
|
||||||
|
print("Done\nError list:")
|
||||||
|
for err in scrape_errors:
|
||||||
|
print(f" - {err}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Initialize logging
|
||||||
|
get_log_manager()
|
||||||
|
logging.info("Starting DosVault ROM scraper")
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
323
src/backfill_images.py
Normal file
323
src/backfill_images.py
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Backfill script to download images for existing games in the database.
|
||||||
|
This script finds games that have remote image URLs but no local image files,
|
||||||
|
and downloads them with proper error handling and progress tracking.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import create_engine, select, func
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
try:
|
||||||
|
from libs.config import Config
|
||||||
|
from libs.database import Game_table, Metadata_table
|
||||||
|
from libs.functions import download_image, get_image_filename
|
||||||
|
from libs.apis import IGDB
|
||||||
|
except ImportError:
|
||||||
|
import sys
|
||||||
|
sys.path.append(str(Path(__file__).parent))
|
||||||
|
from libs.config import Config
|
||||||
|
from libs.database import Game_table, Metadata_table
|
||||||
|
from libs.functions import download_image, get_image_filename
|
||||||
|
from libs.apis import IGDB
|
||||||
|
|
||||||
|
class ImageBackfillManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.config = Config()
|
||||||
|
self.engine = create_engine(f"sqlite+pysqlite:///{self.config.database_path}", future=True)
|
||||||
|
self.failed_downloads: List[str] = []
|
||||||
|
self.successful_downloads: int = 0
|
||||||
|
|
||||||
|
def get_image_url(self, image_data: str, image_type: str = 'cover_big') -> Optional[str]:
|
||||||
|
"""Convert image ID or URL to full URL."""
|
||||||
|
if not image_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# If it's already a full URL, return as-is
|
||||||
|
if image_data.startswith('http'):
|
||||||
|
return image_data
|
||||||
|
|
||||||
|
# Skip old numeric-only image IDs (from old IGDB API) - they're no longer valid
|
||||||
|
if image_data.isdigit():
|
||||||
|
print(f" ⚠️ Skipping old numeric image ID: {image_data}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# New IGDB image IDs are alphanumeric (e.g., 'co3ws0')
|
||||||
|
if len(image_data) > 0 and not image_data.isspace():
|
||||||
|
return IGDB.build_cover_url(image_data, image_type)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_games_needing_images(self, limit: Optional[int] = None) -> List[Game_table]:
|
||||||
|
"""Get games that have remote image URLs but no local image files."""
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
stmt = (
|
||||||
|
select(Game_table)
|
||||||
|
.join(Metadata_table)
|
||||||
|
.options(selectinload(Game_table.metadata_obj)) # Eager load relationships
|
||||||
|
.where(
|
||||||
|
(
|
||||||
|
# Has cover image URL but no local cover path
|
||||||
|
(Metadata_table.cover_image.is_not(None)) &
|
||||||
|
(Metadata_table.cover_image_path.is_(None))
|
||||||
|
) | (
|
||||||
|
# Has screenshot URL but no local screenshot path
|
||||||
|
(Metadata_table.screenshot.is_not(None)) &
|
||||||
|
(Metadata_table.screenshot_path.is_(None))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Game_table.title)
|
||||||
|
)
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
stmt = stmt.limit(limit)
|
||||||
|
|
||||||
|
# Load the objects with eager loading
|
||||||
|
games = session.scalars(stmt).all()
|
||||||
|
return games
|
||||||
|
|
||||||
|
def get_stats(self):
|
||||||
|
"""Get statistics about images in the database."""
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
total_games = session.scalar(select(func.count(Game_table.id)))
|
||||||
|
|
||||||
|
games_with_cover_urls = session.scalar(
|
||||||
|
select(func.count(Metadata_table.id))
|
||||||
|
.where(Metadata_table.cover_image.is_not(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
games_with_local_covers = session.scalar(
|
||||||
|
select(func.count(Metadata_table.id))
|
||||||
|
.where(Metadata_table.cover_image_path.is_not(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
games_with_screenshot_urls = session.scalar(
|
||||||
|
select(func.count(Metadata_table.id))
|
||||||
|
.where(Metadata_table.screenshot.is_not(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
games_with_local_screenshots = session.scalar(
|
||||||
|
select(func.count(Metadata_table.id))
|
||||||
|
.where(Metadata_table.screenshot_path.is_not(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_games': total_games,
|
||||||
|
'games_with_cover_urls': games_with_cover_urls,
|
||||||
|
'games_with_local_covers': games_with_local_covers,
|
||||||
|
'games_with_screenshot_urls': games_with_screenshot_urls,
|
||||||
|
'games_with_local_screenshots': games_with_local_screenshots,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def download_images_for_game(self, game: Game_table, session: aiohttp.ClientSession) -> dict:
|
||||||
|
"""Download images for a single game."""
|
||||||
|
result = {
|
||||||
|
'game_title': game.title,
|
||||||
|
'cover_success': False,
|
||||||
|
'screenshot_success': False,
|
||||||
|
'cover_path': None,
|
||||||
|
'screenshot_path': None,
|
||||||
|
'errors': []
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = game.metadata_obj
|
||||||
|
if not metadata:
|
||||||
|
result['errors'].append('No metadata found')
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Download cover image if URL exists but no local file
|
||||||
|
if metadata.cover_image and not metadata.cover_image_path:
|
||||||
|
try:
|
||||||
|
cover_url = self.get_image_url(metadata.cover_image, 'cover_big')
|
||||||
|
if cover_url:
|
||||||
|
cover_filename = get_image_filename(cover_url, game.title, 'cover')
|
||||||
|
cover_path = self.config.images_path / cover_filename
|
||||||
|
|
||||||
|
if await download_image(cover_url, cover_path, session):
|
||||||
|
result['cover_success'] = True
|
||||||
|
result['cover_path'] = cover_path
|
||||||
|
else:
|
||||||
|
result['errors'].append(f'Failed to download cover: {cover_url}')
|
||||||
|
else:
|
||||||
|
result['errors'].append(f'Invalid cover image data: {metadata.cover_image}')
|
||||||
|
except Exception as e:
|
||||||
|
result['errors'].append(f'Cover download error: {str(e)}')
|
||||||
|
|
||||||
|
# Download screenshot if URL exists but no local file
|
||||||
|
if metadata.screenshot and not metadata.screenshot_path:
|
||||||
|
try:
|
||||||
|
screenshot_url = self.get_image_url(metadata.screenshot, 'screenshot_med')
|
||||||
|
if screenshot_url:
|
||||||
|
screenshot_filename = get_image_filename(screenshot_url, game.title, 'screenshot')
|
||||||
|
screenshot_path = self.config.images_path / screenshot_filename
|
||||||
|
|
||||||
|
if await download_image(screenshot_url, screenshot_path, session):
|
||||||
|
result['screenshot_success'] = True
|
||||||
|
result['screenshot_path'] = screenshot_path
|
||||||
|
else:
|
||||||
|
result['errors'].append(f'Failed to download screenshot: {screenshot_url}')
|
||||||
|
else:
|
||||||
|
result['errors'].append(f'Invalid screenshot image data: {metadata.screenshot}')
|
||||||
|
except Exception as e:
|
||||||
|
result['errors'].append(f'Screenshot download error: {str(e)}')
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def process_batch(self, games: List[Game_table], batch_size: int = 50):
|
||||||
|
"""Process a batch of games with concurrent downloads."""
|
||||||
|
semaphore = asyncio.Semaphore(4) # Limit concurrent downloads
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
||||||
|
async def download_with_semaphore(game):
|
||||||
|
async with semaphore:
|
||||||
|
await asyncio.sleep(0.1) # Small delay to be respectful
|
||||||
|
return await self.download_images_for_game(game, session)
|
||||||
|
|
||||||
|
# Process in batches to avoid overwhelming the database
|
||||||
|
for i in range(0, len(games), batch_size):
|
||||||
|
batch = games[i:i + batch_size]
|
||||||
|
print(f"\nProcessing batch {i//batch_size + 1} ({len(batch)} games)...")
|
||||||
|
|
||||||
|
# Download images concurrently for this batch
|
||||||
|
tasks = [download_with_semaphore(game) for game in batch]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# Update database with successful downloads
|
||||||
|
with Session(self.engine) as db_session:
|
||||||
|
for game, result in zip(batch, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
print(f" ✗ {game.title}: {str(result)}")
|
||||||
|
self.failed_downloads.append(f"{game.title}: {str(result)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update metadata with local paths
|
||||||
|
if result['cover_success']:
|
||||||
|
game.metadata_obj.cover_image_path = result['cover_path']
|
||||||
|
self.successful_downloads += 1
|
||||||
|
|
||||||
|
if result['screenshot_success']:
|
||||||
|
game.metadata_obj.screenshot_path = result['screenshot_path']
|
||||||
|
self.successful_downloads += 1
|
||||||
|
|
||||||
|
# Show progress
|
||||||
|
status = []
|
||||||
|
if result['cover_success']:
|
||||||
|
status.append('cover ✓')
|
||||||
|
if result['screenshot_success']:
|
||||||
|
status.append('screenshot ✓')
|
||||||
|
if result['errors']:
|
||||||
|
status.extend([f"error: {err}" for err in result['errors']])
|
||||||
|
|
||||||
|
print(f" {game.title}: {', '.join(status) if status else 'no images needed'}")
|
||||||
|
|
||||||
|
# Commit batch
|
||||||
|
db_session.commit()
|
||||||
|
print(f" Batch committed to database")
|
||||||
|
|
||||||
|
async def run(self, limit: Optional[int] = None, dry_run: bool = False):
|
||||||
|
"""Run the image backfill process."""
|
||||||
|
print("🖼️ ROM Image Backfill Tool")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Show current statistics
|
||||||
|
stats = self.get_stats()
|
||||||
|
print(f"Database Statistics:")
|
||||||
|
print(f" Total games: {stats['total_games']}")
|
||||||
|
print(f" Games with cover URLs: {stats['games_with_cover_urls']}")
|
||||||
|
print(f" Games with local covers: {stats['games_with_local_covers']}")
|
||||||
|
print(f" Games with screenshot URLs: {stats['games_with_screenshot_urls']}")
|
||||||
|
print(f" Games with local screenshots: {stats['games_with_local_screenshots']}")
|
||||||
|
|
||||||
|
# Use session context for all operations
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
# Get games that need images within the session
|
||||||
|
stmt = (
|
||||||
|
select(Game_table)
|
||||||
|
.join(Metadata_table)
|
||||||
|
.options(selectinload(Game_table.metadata_obj))
|
||||||
|
.where(
|
||||||
|
(
|
||||||
|
# Has cover image URL but no local cover path
|
||||||
|
(Metadata_table.cover_image.is_not(None)) &
|
||||||
|
(Metadata_table.cover_image_path.is_(None))
|
||||||
|
) | (
|
||||||
|
# Has screenshot URL but no local screenshot path
|
||||||
|
(Metadata_table.screenshot.is_not(None)) &
|
||||||
|
(Metadata_table.screenshot_path.is_(None))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Game_table.title)
|
||||||
|
)
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
stmt = stmt.limit(limit)
|
||||||
|
|
||||||
|
games = session.scalars(stmt).all()
|
||||||
|
print(f"\nFound {len(games)} games needing image downloads")
|
||||||
|
|
||||||
|
if not games:
|
||||||
|
print("✅ All games already have local images!")
|
||||||
|
return
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print("\n🔍 DRY RUN - showing first 10 games that would be processed:")
|
||||||
|
for i, game in enumerate(games[:10]):
|
||||||
|
metadata = game.metadata_obj
|
||||||
|
print(f" {i+1}. {game.title}")
|
||||||
|
if metadata.cover_image and not metadata.cover_image_path:
|
||||||
|
cover_url = self.get_image_url(metadata.cover_image, 'cover_big')
|
||||||
|
print(f" Cover: {cover_url or metadata.cover_image}")
|
||||||
|
if metadata.screenshot and not metadata.screenshot_path:
|
||||||
|
screenshot_url = self.get_image_url(metadata.screenshot, 'screenshot_med')
|
||||||
|
print(f" Screenshot: {screenshot_url or metadata.screenshot}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Confirm before proceeding
|
||||||
|
proceed = input(f"\nDownload images for {len(games)} games? [y/N]: ").strip().lower()
|
||||||
|
if proceed != 'y':
|
||||||
|
print("Cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process the games
|
||||||
|
await self.process_batch(games)
|
||||||
|
|
||||||
|
# Show final results
|
||||||
|
print(f"\n✅ Backfill Complete!")
|
||||||
|
print(f" Successfully downloaded: {self.successful_downloads} images")
|
||||||
|
print(f" Failed downloads: {len(self.failed_downloads)}")
|
||||||
|
|
||||||
|
if self.failed_downloads:
|
||||||
|
print(f"\nFailed Downloads:")
|
||||||
|
for failure in self.failed_downloads[:10]: # Show first 10
|
||||||
|
print(f" - {failure}")
|
||||||
|
if len(self.failed_downloads) > 10:
|
||||||
|
print(f" ... and {len(self.failed_downloads) - 10} more")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Download images for existing ROM entries")
|
||||||
|
parser.add_argument('--limit', type=int, help='Limit number of games to process')
|
||||||
|
parser.add_argument('--dry-run', action='store_true', help='Show what would be done without downloading')
|
||||||
|
parser.add_argument('--stats-only', action='store_true', help='Show statistics only')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
manager = ImageBackfillManager()
|
||||||
|
|
||||||
|
if args.stats_only:
|
||||||
|
stats = manager.get_stats()
|
||||||
|
print("Database Statistics:")
|
||||||
|
for key, value in stats.items():
|
||||||
|
print(f" {key}: {value}")
|
||||||
|
return
|
||||||
|
|
||||||
|
await manager.run(limit=args.limit, dry_run=args.dry_run)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
44
src/create_admin.py
Executable file
44
src/create_admin.py
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from libs.config import Config
|
||||||
|
from libs.database import Base, User_table, UserRole
|
||||||
|
from libs.auth import AuthManager
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def create_admin_user():
|
||||||
|
config = Config()
|
||||||
|
engine = create_engine(f"sqlite+pysqlite:///{config.database_path}")
|
||||||
|
# Database tables are now managed by migrations
|
||||||
|
# Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
# Check if admin user exists
|
||||||
|
existing_admin = db.query(User_table).filter(User_table.role == UserRole.SUPER.value).first()
|
||||||
|
if existing_admin:
|
||||||
|
print(f"Admin user already exists: {existing_admin.username}")
|
||||||
|
return
|
||||||
|
|
||||||
|
username = input("Enter admin username: ").strip()
|
||||||
|
email = input("Enter admin email: ").strip()
|
||||||
|
password = input("Enter admin password: ").strip()
|
||||||
|
|
||||||
|
if not username or not email or not password:
|
||||||
|
print("All fields are required!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Check if username exists
|
||||||
|
existing_user = db.query(User_table).filter(User_table.username == username).first()
|
||||||
|
if existing_user:
|
||||||
|
print("Username already exists!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
admin_user = AuthManager.create_user(db, username, email, password, UserRole.SUPER.value)
|
||||||
|
print(f"Admin user created successfully: {admin_user.username}")
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_admin_user()
|
||||||
0
src/libs/__init__.py
Normal file
0
src/libs/__init__.py
Normal file
101
src/libs/apis.py
Normal file
101
src/libs/apis.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import requests
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from .config import Config
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
|
class URLS(Enum):
|
||||||
|
IGDB_URL = "https://api.igdb.com/v4"
|
||||||
|
TWITCH_AUTH_URL = "https://id.twitch.tv/oauth2/token"
|
||||||
|
IGDB_GAMES_ENDPOINT = IGDB_URL + "/games"
|
||||||
|
IGDB_COVERS_ENDPOINT = IGDB_URL + "/covers"
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Credentials:
|
||||||
|
client_id: str
|
||||||
|
client_secret: str
|
||||||
|
access_token: str|None = None
|
||||||
|
expiry: int|None = None
|
||||||
|
token_type: str|None = None
|
||||||
|
|
||||||
|
def get_credentials(self) -> Dict:
|
||||||
|
auth_url = URLS.TWITCH_AUTH_URL.value+f"?client_id={self.client_id}&client_secret={self.client_secret}&grant_type=client_credentials"
|
||||||
|
resp = requests.post(auth_url)
|
||||||
|
if not resp.status_code == 200:
|
||||||
|
raise ValueError("Failed to obtain access token from Twitch")
|
||||||
|
else:
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def authenticate(self) -> 'Credentials':
|
||||||
|
credentials: Dict = self.get_credentials()
|
||||||
|
self.access_token = credentials['access_token']
|
||||||
|
self.expiry = credentials['expires_in']
|
||||||
|
self.token_type = credentials['token_type']
|
||||||
|
if not self.access_token:
|
||||||
|
raise ValueError("Failed to obtain access token")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __init__(self, config: Config):
|
||||||
|
self.client_id = config.igdb_client_id
|
||||||
|
self.client_secret = config.igdb_api_key
|
||||||
|
|
||||||
|
|
||||||
|
class IGDB:
|
||||||
|
def __init__(self, credentials: Credentials):
|
||||||
|
self.client_id = credentials.client_id
|
||||||
|
self.access_token = credentials.access_token
|
||||||
|
self.token_type = credentials.token_type
|
||||||
|
if not self.access_token:
|
||||||
|
raise ValueError("Access token is not set. Please authenticate first.")
|
||||||
|
|
||||||
|
def headers(self) -> Dict:
|
||||||
|
if not self.access_token:
|
||||||
|
raise ValueError("Access token is not set. Please authenticate first.")
|
||||||
|
return {
|
||||||
|
"Client-ID": self.client_id,
|
||||||
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def search_game_by_title(self, query: str) -> Dict:
|
||||||
|
if not self.access_token:
|
||||||
|
raise ValueError("Access token is not set. Please authenticate first.")
|
||||||
|
search_url = URLS.IGDB_GAMES_ENDPOINT.value
|
||||||
|
headers = self.headers()
|
||||||
|
# Request full cover and artwork data with expanded fields
|
||||||
|
data = f"""search "{query}"; fields name,summary,first_release_date,rating,platforms.name,genres.name,involved_companies.company.name,cover.image_id,artworks.image_id,themes.name,player_perspectives,id; where platforms = (13); limit 10;"""
|
||||||
|
resp = requests.post(search_url, headers=headers, data=data)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise ValueError(f"Failed to search games: {resp.status_code} - {resp.text}")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def get_cover_details(self, cover_id: int) -> Dict:
|
||||||
|
"""Get cover details from IGDB by cover ID"""
|
||||||
|
if not self.access_token:
|
||||||
|
raise ValueError("Access token is not set. Please authenticate first.")
|
||||||
|
covers_url = URLS.IGDB_COVERS_ENDPOINT.value
|
||||||
|
headers = self.headers()
|
||||||
|
data = f"""fields image_id,url,height,width,game; where id = {cover_id};"""
|
||||||
|
resp = requests.post(covers_url, headers=headers, data=data)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise ValueError(f"Failed to get cover details: {resp.status_code} - {resp.text}")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def get_covers_by_game_id(self, game_id: int) -> Dict:
|
||||||
|
"""Get all covers for a specific game ID"""
|
||||||
|
if not self.access_token:
|
||||||
|
raise ValueError("Access token is not set. Please authenticate first.")
|
||||||
|
covers_url = URLS.IGDB_COVERS_ENDPOINT.value
|
||||||
|
headers = self.headers()
|
||||||
|
data = f"""fields image_id,url,height,width; where game = {game_id};"""
|
||||||
|
resp = requests.post(covers_url, headers=headers, data=data)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise ValueError(f"Failed to get covers for game: {resp.status_code} - {resp.text}")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_cover_url(image_id: str, size: str = "cover_big") -> str:
|
||||||
|
"""Build IGDB cover URL from image_id
|
||||||
|
Size options: thumb, cover_small, screenshot_med, cover_big, logo_med, screenshot_big, screenshot_huge, thumb, micro, 720p, 1080p
|
||||||
|
"""
|
||||||
|
return f"https://images.igdb.com/igdb/image/upload/t_{size}/{image_id}.jpg"
|
||||||
74
src/libs/auth.py
Normal file
74
src/libs/auth.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import select
|
||||||
|
from .database import User_table, UserRole
|
||||||
|
|
||||||
|
SECRET_KEY = "your-secret-key-change-this-in-production"
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthManager:
|
||||||
|
@staticmethod
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def verify_token(token: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
return None
|
||||||
|
return username
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def authenticate_user(session: Session, username: str, password: str) -> Optional[User_table]:
|
||||||
|
user = session.scalar(select(User_table).where(User_table.username == username))
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not AuthManager.verify_password(password, user.password_hash):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_user_by_username(session: Session, username: str) -> Optional[User_table]:
|
||||||
|
return session.scalar(select(User_table).where(User_table.username == username))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_user(session: Session, username: str, email: str, password: str, role: str = UserRole.NORMAL.value) -> User_table:
|
||||||
|
hashed_password = AuthManager.get_password_hash(password)
|
||||||
|
user = User_table(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
password_hash=hashed_password,
|
||||||
|
role=role
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
113
src/libs/config.py
Normal file
113
src/libs/config.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Dict
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Check for environment variable override (used in Docker)
|
||||||
|
if os.getenv("DOSFRONTEND_CONFIG_DIR"):
|
||||||
|
DOSFRONTEND_CONFIG_DIR: Path = Path(os.getenv("DOSFRONTEND_CONFIG_DIR"))
|
||||||
|
else:
|
||||||
|
# Default to XDG config directory for regular installations
|
||||||
|
XDG_CONFIG_HOME: Path = Path(Path.home()).joinpath(".config")
|
||||||
|
DOSFRONTEND_CONFIG_DIR: Path = XDG_CONFIG_HOME.joinpath("dosfrontend")
|
||||||
|
|
||||||
|
DOSFRONTEND_CONFIG_FILE: Path = DOSFRONTEND_CONFIG_DIR.joinpath("config.json")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
path: Path = DOSFRONTEND_CONFIG_FILE
|
||||||
|
rom_path: Path = DOSFRONTEND_CONFIG_DIR.joinpath("roms")
|
||||||
|
metadata_path: Path = DOSFRONTEND_CONFIG_DIR.joinpath("metadata")
|
||||||
|
database_path: Path = DOSFRONTEND_CONFIG_DIR.joinpath("roms.db")
|
||||||
|
images_path: Path = DOSFRONTEND_CONFIG_DIR.joinpath("images")
|
||||||
|
host: str = "localhost"
|
||||||
|
port: int = 8080
|
||||||
|
websocket_port: int = 8081
|
||||||
|
igdb_api_key: str = ""
|
||||||
|
igdb_client_id: str = ""
|
||||||
|
|
||||||
|
def __init__(self, path: Optional[Path] = None):
|
||||||
|
if path:
|
||||||
|
self.path = path
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
def load_env_secrets(self) -> Dict[str, str] | None:
|
||||||
|
secrets: Dict[str, str] = {}
|
||||||
|
igdb_api_key = os.getenv("IGDB_SECRET_KEY")
|
||||||
|
igdb_client_id = os.getenv("IGDB_CLIENT_ID")
|
||||||
|
if not igdb_api_key or not igdb_client_id:
|
||||||
|
file_path: Path = Path(__file__)
|
||||||
|
env_path: Path = file_path.parent.parent.parent.joinpath(".env")
|
||||||
|
if not env_path.exists():
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
with env_path.open('r') as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.strip().split("=", 1)
|
||||||
|
key, value = key.strip(), value.strip('"').strip("'")
|
||||||
|
secrets[key] = value
|
||||||
|
f.close()
|
||||||
|
if secrets.get("IGDB_SECRET_KEY") and secrets.get("IGDB_CLIENT_ID"):
|
||||||
|
return secrets
|
||||||
|
else: return None
|
||||||
|
else:
|
||||||
|
secrets = {
|
||||||
|
"IGDB_SECRET_KEY": igdb_api_key,
|
||||||
|
"IGDB_CLIENT_ID": igdb_client_id,
|
||||||
|
}
|
||||||
|
return secrets
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"rom_path": str(self.rom_path),
|
||||||
|
"metadata_path": str(self.metadata_path),
|
||||||
|
"host": self.host,
|
||||||
|
"port": self.port,
|
||||||
|
"websocket_port": self.websocket_port,
|
||||||
|
"igdb_api_key": self.igdb_api_key,
|
||||||
|
"igdb_client_id": self.igdb_client_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
if not self.path.parent.exists():
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
rom_path = input(f"Enter the path to your ROMs [{self.rom_path}] enter for default: ").strip()
|
||||||
|
metadata_path = input(f"Enter the path to your metadata [{self.metadata_path}] enter for default: ").strip()
|
||||||
|
self.rom_path = Path(rom_path) if rom_path else self.rom_path
|
||||||
|
self.metadata_path = Path(metadata_path) if metadata_path else self.metadata_path
|
||||||
|
if not self.rom_path.exists():
|
||||||
|
self.rom_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not self.metadata_path.exists():
|
||||||
|
self.metadata_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not self.images_path.exists():
|
||||||
|
self.images_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(self.path, 'w') as f:
|
||||||
|
json.dump(self.to_dict(), f, indent=4)
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
def load(self) -> "Config":
|
||||||
|
if self.path.exists():
|
||||||
|
with open(self.path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self.rom_path = Path(data.get("rom_path", str(self.rom_path)))
|
||||||
|
self.metadata_path = Path(data.get("metadata_path", str(self.metadata_path)))
|
||||||
|
self.host = data.get("host", self.host)
|
||||||
|
self.port = data.get("port", self.port)
|
||||||
|
self.websocket_port = data.get("websocket_port", self.websocket_port)
|
||||||
|
if self.igdb_api_key == "" or self.igdb_client_id == "":
|
||||||
|
secrets = self.load_env_secrets()
|
||||||
|
if secrets:
|
||||||
|
self.igdb_api_key = secrets.get("IGDB_SECRET_KEY", "")
|
||||||
|
self.igdb_client_id = secrets.get("IGDB_CLIENT_ID", "")
|
||||||
|
f.close()
|
||||||
|
self.save()
|
||||||
|
return self
|
||||||
|
f.close()
|
||||||
|
else:
|
||||||
|
self.save()
|
||||||
|
self.load()
|
||||||
|
return self
|
||||||
241
src/libs/database.py
Normal file
241
src/libs/database.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum as PyEnum
|
||||||
|
from sqlalchemy import (
|
||||||
|
String,
|
||||||
|
Integer,
|
||||||
|
ForeignKey,
|
||||||
|
Table,
|
||||||
|
Column,
|
||||||
|
UniqueConstraint,
|
||||||
|
MetaData,
|
||||||
|
select,
|
||||||
|
DateTime,
|
||||||
|
Boolean
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session
|
||||||
|
from sqlalchemy.types import TypeDecorator
|
||||||
|
from .objects import Roms
|
||||||
|
from .functions import extract_year_from_title
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Base (with naming convention; nice for Alembic) -------------------------
|
||||||
|
convention = {
|
||||||
|
"ix": "ix_%(column_0_label)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s",
|
||||||
|
}
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
metadata = MetaData(naming_convention=convention)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- PathType to store pathlib.Path as TEXT ----------------------------------
|
||||||
|
class PathType(TypeDecorator):
|
||||||
|
impl = String
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
return None if value is None else str(value)
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return None if value is None else Path(value)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Association tables (use Column, not mapped_column) ----------------------
|
||||||
|
metadata_tags = Table(
|
||||||
|
"metadata_tags",
|
||||||
|
Base.metadata,
|
||||||
|
Column("metadata_id", ForeignKey("metadata.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("tag_id", ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
UniqueConstraint("metadata_id", "tag_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata_genres = Table(
|
||||||
|
"metadata_genres",
|
||||||
|
Base.metadata,
|
||||||
|
Column("metadata_id", ForeignKey("metadata.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("genre_id", ForeignKey("genre.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
UniqueConstraint("metadata_id", "genre_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user_favorites = Table(
|
||||||
|
"user_favorites",
|
||||||
|
Base.metadata,
|
||||||
|
Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("game_id", ForeignKey("game.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
UniqueConstraint("user_id", "game_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(PyEnum):
|
||||||
|
DEMO = "demo"
|
||||||
|
NORMAL = "normal"
|
||||||
|
SUPER = "super"
|
||||||
|
|
||||||
|
|
||||||
|
class User_table(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||||
|
email: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255))
|
||||||
|
role: Mapped[str] = mapped_column(String(20), default=UserRole.NORMAL.value)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
favorites: Mapped[List["Game_table"]] = relationship(
|
||||||
|
secondary=user_favorites,
|
||||||
|
back_populates="favorited_by",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"User(id={self.id}, username={self.username!r}, role={self.role})"
|
||||||
|
|
||||||
|
|
||||||
|
class Tags_table(Base):
|
||||||
|
__tablename__ = "tags"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(30), unique=True, index=True)
|
||||||
|
|
||||||
|
games: Mapped[List["Metadata_table"]] = relationship(
|
||||||
|
secondary=metadata_tags,
|
||||||
|
back_populates="tags",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Genre_table(Base):
|
||||||
|
__tablename__ = "genre"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(30), unique=True, index=True)
|
||||||
|
|
||||||
|
games: Mapped[List["Metadata_table"]] = relationship(
|
||||||
|
secondary=metadata_genres,
|
||||||
|
back_populates="genre",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Game_table(Base):
|
||||||
|
__tablename__ = "game"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(66), index=True)
|
||||||
|
path: Mapped[Path] = mapped_column(PathType(), unique=True, nullable=False)
|
||||||
|
|
||||||
|
metadata_obj: Mapped[Optional["Metadata_table"]] = relationship(
|
||||||
|
back_populates="game",
|
||||||
|
uselist=False,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
favorited_by: Mapped[List["User_table"]] = relationship(
|
||||||
|
secondary=user_favorites,
|
||||||
|
back_populates="favorites",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Game(id={self.id}, title={self.title!r}, path={str(self.path)!r})"
|
||||||
|
|
||||||
|
|
||||||
|
class Metadata_table(Base):
|
||||||
|
__tablename__ = "metadata"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
game_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("game.id", ondelete="CASCADE"),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
title: Mapped[str] = mapped_column(String(66))
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
year: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
developer: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
publisher: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
players: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
cover_image: Mapped[Optional[str]] = mapped_column(String, nullable=True) # Remote URL
|
||||||
|
screenshot: Mapped[Optional[str]] = mapped_column(String, nullable=True) # Remote URL
|
||||||
|
cover_image_path: Mapped[Optional[Path]] = mapped_column(PathType(), nullable=True) # Local file path
|
||||||
|
screenshot_path: Mapped[Optional[Path]] = mapped_column(PathType(), nullable=True) # Local file path
|
||||||
|
|
||||||
|
genre: Mapped[List[Genre_table]] = relationship(
|
||||||
|
secondary=metadata_genres,
|
||||||
|
back_populates="games",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
tags: Mapped[List[Tags_table]] = relationship(
|
||||||
|
secondary=metadata_tags,
|
||||||
|
back_populates="games",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
game: Mapped["Game_table"] = relationship(back_populates="metadata_obj")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Metadata(id={self.id}, game_id={self.game_id}, title={self.title!r}, year={self.year})"
|
||||||
|
|
||||||
|
def _get_or_create_by_name(session: Session, model, name: str):
|
||||||
|
obj = session.scalar(select(model).where(model.name == name))
|
||||||
|
if obj is None:
|
||||||
|
obj = model(name=name)
|
||||||
|
session.add(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def get_existing_rom_paths(session: Session) -> set[Path]:
|
||||||
|
return {game.path.resolve() for game in session.scalars(select(Game_table)).all()}
|
||||||
|
|
||||||
|
def ingest_roms(roms: Roms, session: Session, *, batch: int = 200) -> int:
|
||||||
|
n = 0
|
||||||
|
for g in roms.list:
|
||||||
|
game = session.scalar(select(Game_table).where(Game_table.path == g.path))
|
||||||
|
if game is None:
|
||||||
|
game = Game_table(title=g.title, path=g.path)
|
||||||
|
session.add(game)
|
||||||
|
else:
|
||||||
|
game.title = g.title
|
||||||
|
mdto = g.metadata
|
||||||
|
md = game.metadata_obj
|
||||||
|
if md is None:
|
||||||
|
md = Metadata_table(game=game, title=mdto.title or g.title)
|
||||||
|
session.add(md)
|
||||||
|
|
||||||
|
md.title = mdto.title or g.title
|
||||||
|
md.description = mdto.description
|
||||||
|
md.year = mdto.year if mdto.year is not None else extract_year_from_title(md.title)
|
||||||
|
md.developer = mdto.developer
|
||||||
|
md.publisher = mdto.publisher
|
||||||
|
md.players = mdto.players
|
||||||
|
md.cover_image = mdto.cover_image
|
||||||
|
md.screenshot = mdto.screenshot
|
||||||
|
md.cover_image_path = mdto.cover_image_path
|
||||||
|
md.screenshot_path = mdto.screenshot_path
|
||||||
|
|
||||||
|
try: genres = sorted({s.strip() for s in (mdto.genre or []) if s and s.strip()})
|
||||||
|
except: genres = []
|
||||||
|
try: tags = sorted({s.strip() for s in (mdto.tags or []) if s and s.strip()})
|
||||||
|
except: tags = []
|
||||||
|
|
||||||
|
md.genre = [_get_or_create_by_name(session, Genre_table, name) for name in genres]
|
||||||
|
md.tags = [_get_or_create_by_name(session, Tags_table, name) for name in tags]
|
||||||
|
|
||||||
|
n += 1
|
||||||
|
if n % batch == 0:
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return n
|
||||||
|
|
||||||
78
src/libs/functions.py
Normal file
78
src/libs/functions.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from typing import Optional
|
||||||
|
import re
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
from pathlib import Path
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
YEAR_RE = re.compile(r"\((\d{4})\)")
|
||||||
|
PARENS_RE = re.compile(r"\([^)]*\)")
|
||||||
|
|
||||||
|
def extract_year_from_title(title: Optional[str]) -> Optional[int]:
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
m = YEAR_RE.search(title)
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
def clean_title(title: str) -> str:
|
||||||
|
# remove anything in (...) from the title
|
||||||
|
cleaned = PARENS_RE.sub("", title)
|
||||||
|
return " ".join(cleaned.split()).strip()
|
||||||
|
|
||||||
|
async def download_image(url: str, save_path: Path, session: aiohttp.ClientSession) -> bool:
|
||||||
|
"""
|
||||||
|
Download an image from URL and save it locally.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The image URL to download
|
||||||
|
save_path: Local path where to save the image
|
||||||
|
session: aiohttp client session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if download was successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create directory if it doesn't exist
|
||||||
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
async with session.get(url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
content = await response.read()
|
||||||
|
with open(save_path, 'wb') as f:
|
||||||
|
f.write(content)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"Failed to download {url}: HTTP {response.status}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error downloading {url}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_image_filename(url: str, game_title: str, image_type: str) -> str:
|
||||||
|
"""
|
||||||
|
Generate a unique filename for an image based on game title and URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The image URL
|
||||||
|
game_title: The game title
|
||||||
|
image_type: 'cover' or 'screenshot'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Generated filename
|
||||||
|
"""
|
||||||
|
# Create a hash of the URL to ensure uniqueness
|
||||||
|
url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
|
||||||
|
|
||||||
|
# Clean game title for filename
|
||||||
|
clean_name = re.sub(r'[^\w\-_\. ]', '', game_title)
|
||||||
|
clean_name = re.sub(r'\s+', '_', clean_name).strip('_')
|
||||||
|
|
||||||
|
# Get file extension from URL
|
||||||
|
try:
|
||||||
|
ext = Path(url.split('?')[0]).suffix
|
||||||
|
if not ext:
|
||||||
|
ext = '.jpg' # Default extension
|
||||||
|
except:
|
||||||
|
ext = '.jpg'
|
||||||
|
|
||||||
|
return f"{clean_name}_{image_type}_{url_hash}{ext}"
|
||||||
220
src/libs/logging.py
Normal file
220
src/libs/logging.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Logging configuration for DosVault application."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .config import Config
|
||||||
|
except ImportError:
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class JSONFormatter(logging.Formatter):
|
||||||
|
"""Custom JSON formatter for structured logging."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
log_entry = {
|
||||||
|
'timestamp': datetime.fromtimestamp(record.created).isoformat(),
|
||||||
|
'level': record.levelname,
|
||||||
|
'module': record.name,
|
||||||
|
'message': record.getMessage(),
|
||||||
|
'filename': record.filename,
|
||||||
|
'line_number': record.lineno,
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.exc_info:
|
||||||
|
log_entry['traceback'] = self.formatException(record.exc_info)
|
||||||
|
|
||||||
|
return json.dumps(log_entry)
|
||||||
|
|
||||||
|
|
||||||
|
class LogManager:
|
||||||
|
"""Manages logging configuration and log file access."""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[Config] = None):
|
||||||
|
self.config = config or Config()
|
||||||
|
# Use the existing config directory structure
|
||||||
|
self.log_dir = self.config.path.parent / "logs"
|
||||||
|
self.log_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
self.log_file = self.log_dir / "application.log"
|
||||||
|
self.error_log_file = self.log_dir / "error.log"
|
||||||
|
|
||||||
|
self._setup_logging()
|
||||||
|
|
||||||
|
def _setup_logging(self):
|
||||||
|
"""Configure logging handlers and formatters."""
|
||||||
|
# Create root logger
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# Clear existing handlers
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
|
||||||
|
# Console handler with simple format
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_formatter = logging.Formatter(
|
||||||
|
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
console_handler.setFormatter(console_formatter)
|
||||||
|
console_handler.setLevel(logging.INFO)
|
||||||
|
root_logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# File handler with JSON format
|
||||||
|
file_handler = logging.handlers.RotatingFileHandler(
|
||||||
|
self.log_file,
|
||||||
|
maxBytes=10*1024*1024, # 10MB
|
||||||
|
backupCount=5
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(JSONFormatter())
|
||||||
|
file_handler.setLevel(logging.DEBUG)
|
||||||
|
root_logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
# Error file handler
|
||||||
|
error_handler = logging.handlers.RotatingFileHandler(
|
||||||
|
self.error_log_file,
|
||||||
|
maxBytes=5*1024*1024, # 5MB
|
||||||
|
backupCount=3
|
||||||
|
)
|
||||||
|
error_formatter = logging.Formatter(
|
||||||
|
'%(asctime)s - %(name)s - %(levelname)s - %(message)s\n%(pathname)s:%(lineno)d\n'
|
||||||
|
)
|
||||||
|
error_handler.setFormatter(error_formatter)
|
||||||
|
error_handler.setLevel(logging.ERROR)
|
||||||
|
root_logger.addHandler(error_handler)
|
||||||
|
|
||||||
|
# Log startup
|
||||||
|
logging.info("DosVault logging system initialized")
|
||||||
|
|
||||||
|
def get_recent_logs(self, limit: int = 1000, level_filter: Optional[str] = None, since: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""Get recent log entries from the log file."""
|
||||||
|
logs = []
|
||||||
|
|
||||||
|
if not self.log_file.exists():
|
||||||
|
return logs
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse the since timestamp if provided
|
||||||
|
since_datetime = None
|
||||||
|
if since:
|
||||||
|
try:
|
||||||
|
since_datetime = datetime.fromisoformat(since.replace('Z', '+00:00'))
|
||||||
|
except ValueError:
|
||||||
|
logging.warning(f"Invalid since timestamp format: {since}")
|
||||||
|
|
||||||
|
with open(self.log_file, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
# Get the last 'limit*2' lines to ensure we have enough after filtering
|
||||||
|
recent_lines = lines[-(limit*2):] if len(lines) > limit*2 else lines
|
||||||
|
|
||||||
|
for line in recent_lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_entry = json.loads(line)
|
||||||
|
|
||||||
|
# Apply time filter if specified
|
||||||
|
if since_datetime:
|
||||||
|
try:
|
||||||
|
log_datetime = datetime.fromisoformat(log_entry['timestamp'])
|
||||||
|
|
||||||
|
# Handle timezone-aware/naive comparison
|
||||||
|
if log_datetime.tzinfo is None and since_datetime.tzinfo is not None:
|
||||||
|
# Make log_datetime timezone-aware (assume UTC)
|
||||||
|
log_datetime = log_datetime.replace(tzinfo=timezone.utc)
|
||||||
|
elif log_datetime.tzinfo is not None and since_datetime.tzinfo is None:
|
||||||
|
# Make since_datetime timezone-aware (assume UTC)
|
||||||
|
since_datetime = since_datetime.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if log_datetime <= since_datetime:
|
||||||
|
continue
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
pass # Skip time filtering for invalid timestamps
|
||||||
|
|
||||||
|
# Apply level filter if specified
|
||||||
|
if level_filter and log_entry.get('level') != level_filter:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logs.append(log_entry)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Handle non-JSON log lines
|
||||||
|
logs.append({
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'level': 'INFO',
|
||||||
|
'module': 'system',
|
||||||
|
'message': line
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by timestamp and limit results
|
||||||
|
logs.sort(key=lambda x: x.get('timestamp', ''))
|
||||||
|
logs = logs[-limit:] if len(logs) > limit else logs
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error reading log file: {e}")
|
||||||
|
|
||||||
|
return logs
|
||||||
|
|
||||||
|
def get_log_files(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get information about available log files."""
|
||||||
|
files = []
|
||||||
|
|
||||||
|
for log_file in self.log_dir.glob("*.log*"):
|
||||||
|
try:
|
||||||
|
stat = log_file.stat()
|
||||||
|
files.append({
|
||||||
|
'name': log_file.name,
|
||||||
|
'path': str(log_file),
|
||||||
|
'size': stat.st_size,
|
||||||
|
'modified': datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error getting file info for {log_file}: {e}")
|
||||||
|
|
||||||
|
return sorted(files, key=lambda x: x['modified'], reverse=True)
|
||||||
|
|
||||||
|
def clear_old_logs(self, keep_days: int = 7) -> int:
|
||||||
|
"""Clear log files older than specified days."""
|
||||||
|
cleared_count = 0
|
||||||
|
cutoff_time = datetime.now().timestamp() - (keep_days * 24 * 3600)
|
||||||
|
|
||||||
|
for log_file in self.log_dir.glob("*.log.*"): # Rotated logs only
|
||||||
|
try:
|
||||||
|
if log_file.stat().st_mtime < cutoff_time:
|
||||||
|
log_file.unlink()
|
||||||
|
cleared_count += 1
|
||||||
|
logging.info(f"Cleared old log file: {log_file.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error clearing log file {log_file}: {e}")
|
||||||
|
|
||||||
|
return cleared_count
|
||||||
|
|
||||||
|
def get_log_file_content(self, file_type: str = "application") -> Optional[Path]:
|
||||||
|
"""Get the path to a specific log file for download."""
|
||||||
|
if file_type == "application":
|
||||||
|
return self.log_file if self.log_file.exists() else None
|
||||||
|
elif file_type == "error":
|
||||||
|
return self.error_log_file if self.error_log_file.exists() else None
|
||||||
|
else:
|
||||||
|
# Look for specific log file
|
||||||
|
log_file = self.log_dir / f"{file_type}.log"
|
||||||
|
return log_file if log_file.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
# Global log manager instance - initialized lazily
|
||||||
|
log_manager = None
|
||||||
|
|
||||||
|
def get_log_manager() -> LogManager:
|
||||||
|
"""Get or create the global log manager instance."""
|
||||||
|
global log_manager
|
||||||
|
if log_manager is None:
|
||||||
|
log_manager = LogManager()
|
||||||
|
return log_manager
|
||||||
29
src/libs/objects.py
Normal file
29
src/libs/objects.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Metadata:
|
||||||
|
title: str = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
year: Optional[int] = None
|
||||||
|
developer: Optional[str] = None
|
||||||
|
publisher: Optional[str] = None
|
||||||
|
genre: Optional[List[str]] = field(default_factory=list)
|
||||||
|
players: Optional[int] = None
|
||||||
|
cover_image: Optional[str] = None # Remote URL
|
||||||
|
screenshot: Optional[str] = None # Remote URL
|
||||||
|
cover_image_path: Optional[Path] = None # Local file path
|
||||||
|
screenshot_path: Optional[Path] = None # Local file path
|
||||||
|
tags: Optional[List[str]] = field(default_factory=list)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Game:
|
||||||
|
title: str
|
||||||
|
path: Path
|
||||||
|
metadata: Metadata|None = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Roms:
|
||||||
|
list: List[Game] = field(default_factory=list)
|
||||||
|
|
||||||
149
src/migrate.py
Executable file
149
src/migrate.py
Executable file
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Database migration management script.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic import command
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
from alembic.runtime.environment import EnvironmentContext
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
# Add current directory to path for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from libs.config import Config as AppConfig
|
||||||
|
from libs.database import Base
|
||||||
|
|
||||||
|
def get_alembic_config():
|
||||||
|
"""Get Alembic configuration object."""
|
||||||
|
alembic_cfg = Config(str(Path(__file__).parent.parent / "alembic.ini"))
|
||||||
|
app_config = AppConfig()
|
||||||
|
alembic_cfg.set_main_option("sqlalchemy.url", f"sqlite:///{app_config.database_path}")
|
||||||
|
return alembic_cfg
|
||||||
|
|
||||||
|
def init_database():
|
||||||
|
"""Initialize database tables without Alembic for first-time setup."""
|
||||||
|
app_config = AppConfig()
|
||||||
|
engine = create_engine(f"sqlite:///{app_config.database_path}")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
print(f"Database initialized at {app_config.database_path}")
|
||||||
|
|
||||||
|
def create_migration(message: str):
|
||||||
|
"""Create a new migration file."""
|
||||||
|
alembic_cfg = get_alembic_config()
|
||||||
|
command.revision(alembic_cfg, message=message, autogenerate=True)
|
||||||
|
print(f"Created migration: {message}")
|
||||||
|
|
||||||
|
def upgrade_database(revision: str = "head"):
|
||||||
|
"""Upgrade database to a specific revision."""
|
||||||
|
alembic_cfg = get_alembic_config()
|
||||||
|
command.upgrade(alembic_cfg, revision)
|
||||||
|
print(f"Database upgraded to {revision}")
|
||||||
|
|
||||||
|
def downgrade_database(revision: str):
|
||||||
|
"""Downgrade database to a specific revision."""
|
||||||
|
alembic_cfg = get_alembic_config()
|
||||||
|
command.downgrade(alembic_cfg, revision)
|
||||||
|
print(f"Database downgraded to {revision}")
|
||||||
|
|
||||||
|
def show_history():
|
||||||
|
"""Show migration history."""
|
||||||
|
alembic_cfg = get_alembic_config()
|
||||||
|
command.history(alembic_cfg)
|
||||||
|
|
||||||
|
def show_current():
|
||||||
|
"""Show current database revision."""
|
||||||
|
alembic_cfg = get_alembic_config()
|
||||||
|
command.current(alembic_cfg)
|
||||||
|
|
||||||
|
def stamp_database(revision: str = "head"):
|
||||||
|
"""Mark the database as being at a specific revision without running migrations."""
|
||||||
|
alembic_cfg = get_alembic_config()
|
||||||
|
command.stamp(alembic_cfg, revision)
|
||||||
|
print(f"Database stamped at {revision}")
|
||||||
|
|
||||||
|
def check_database_exists():
|
||||||
|
"""Check if database and migration table exist."""
|
||||||
|
app_config = AppConfig()
|
||||||
|
db_path = Path(app_config.database_path)
|
||||||
|
|
||||||
|
if not db_path.exists():
|
||||||
|
print("Database does not exist.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if alembic_version table exists
|
||||||
|
engine = create_engine(f"sqlite:///{app_config.database_path}")
|
||||||
|
inspector = inspect(engine)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if "alembic_version" not in tables:
|
||||||
|
print("Database exists but is not under Alembic control.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("Database exists and is under Alembic control.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Database migration management")
|
||||||
|
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||||
|
|
||||||
|
# Init command
|
||||||
|
subparsers.add_parser('init', help='Initialize database (for first-time setup)')
|
||||||
|
|
||||||
|
# Stamp command
|
||||||
|
stamp_parser = subparsers.add_parser('stamp', help='Mark database as being at a specific revision')
|
||||||
|
stamp_parser.add_argument('revision', nargs='?', default='head', help='Revision to stamp (default: head)')
|
||||||
|
|
||||||
|
# Create migration command
|
||||||
|
create_parser = subparsers.add_parser('create', help='Create a new migration')
|
||||||
|
create_parser.add_argument('message', help='Migration message')
|
||||||
|
|
||||||
|
# Upgrade command
|
||||||
|
upgrade_parser = subparsers.add_parser('upgrade', help='Upgrade database')
|
||||||
|
upgrade_parser.add_argument('revision', nargs='?', default='head', help='Target revision (default: head)')
|
||||||
|
|
||||||
|
# Downgrade command
|
||||||
|
downgrade_parser = subparsers.add_parser('downgrade', help='Downgrade database')
|
||||||
|
downgrade_parser.add_argument('revision', help='Target revision')
|
||||||
|
|
||||||
|
# History command
|
||||||
|
subparsers.add_parser('history', help='Show migration history')
|
||||||
|
|
||||||
|
# Current command
|
||||||
|
subparsers.add_parser('current', help='Show current database revision')
|
||||||
|
|
||||||
|
# Check command
|
||||||
|
subparsers.add_parser('check', help='Check database status')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.command == 'init':
|
||||||
|
init_database()
|
||||||
|
elif args.command == 'stamp':
|
||||||
|
stamp_database(args.revision)
|
||||||
|
elif args.command == 'create':
|
||||||
|
create_migration(args.message)
|
||||||
|
elif args.command == 'upgrade':
|
||||||
|
upgrade_database(args.revision)
|
||||||
|
elif args.command == 'downgrade':
|
||||||
|
downgrade_database(args.revision)
|
||||||
|
elif args.command == 'history':
|
||||||
|
show_history()
|
||||||
|
elif args.command == 'current':
|
||||||
|
show_current()
|
||||||
|
elif args.command == 'check':
|
||||||
|
check_database_exists()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
274
src/refresh_covers.py
Executable file
274
src/refresh_covers.py
Executable file
@@ -0,0 +1,274 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Script to refresh cover image metadata for games with old numeric image IDs.
|
||||||
|
This will re-query IGDB for fresh image data and download the images locally.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import create_engine, select, func
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
try:
|
||||||
|
from libs.config import Config
|
||||||
|
from libs.database import Game_table, Metadata_table
|
||||||
|
from libs.functions import download_image, get_image_filename, clean_title
|
||||||
|
from libs.apis import Credentials, IGDB
|
||||||
|
except ImportError:
|
||||||
|
import sys
|
||||||
|
sys.path.append(str(Path(__file__).parent))
|
||||||
|
from libs.config import Config
|
||||||
|
from libs.database import Game_table, Metadata_table
|
||||||
|
from libs.functions import download_image, get_image_filename, clean_title
|
||||||
|
from libs.apis import Credentials, IGDB
|
||||||
|
|
||||||
|
class CoverRefreshManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.config = Config()
|
||||||
|
self.engine = create_engine(f"sqlite+pysqlite:///{self.config.database_path}", future=True)
|
||||||
|
|
||||||
|
# Initialize IGDB API
|
||||||
|
token = Credentials(self.config).authenticate()
|
||||||
|
self.igdb = IGDB(token)
|
||||||
|
|
||||||
|
self.refreshed_count = 0
|
||||||
|
self.download_success_count = 0
|
||||||
|
self.failed_refreshes: List[str] = []
|
||||||
|
|
||||||
|
def get_games_with_old_image_ids(self, limit: Optional[int] = None) -> List[Game_table]:
|
||||||
|
"""Get games that have old numeric image IDs."""
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
stmt = (
|
||||||
|
select(Game_table)
|
||||||
|
.join(Metadata_table)
|
||||||
|
.options(selectinload(Game_table.metadata_obj))
|
||||||
|
.where(
|
||||||
|
# Has old numeric image IDs (not alphanumeric)
|
||||||
|
Metadata_table.cover_image.op('REGEXP')('^[0-9]+$')
|
||||||
|
)
|
||||||
|
.order_by(Game_table.title)
|
||||||
|
)
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
stmt = stmt.limit(limit)
|
||||||
|
|
||||||
|
return session.scalars(stmt).all()
|
||||||
|
|
||||||
|
async def refresh_game_metadata(self, game: Game_table, session: aiohttp.ClientSession) -> dict:
|
||||||
|
"""Refresh metadata for a single game and download images."""
|
||||||
|
result = {
|
||||||
|
'game_title': game.title,
|
||||||
|
'api_success': False,
|
||||||
|
'cover_updated': False,
|
||||||
|
'cover_downloaded': False,
|
||||||
|
'screenshot_updated': False,
|
||||||
|
'screenshot_downloaded': False,
|
||||||
|
'errors': []
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Search for fresh game data
|
||||||
|
clean_title_text = clean_title(game.title)
|
||||||
|
igdb_response = self.igdb.search_game_by_title(clean_title_text)
|
||||||
|
|
||||||
|
if not igdb_response or len(igdb_response) == 0:
|
||||||
|
result['errors'].append('No IGDB results found')
|
||||||
|
return result
|
||||||
|
|
||||||
|
game_data = igdb_response[0] # Take the first result
|
||||||
|
result['api_success'] = True
|
||||||
|
|
||||||
|
metadata = game.metadata_obj
|
||||||
|
if not metadata:
|
||||||
|
result['errors'].append('No metadata object found')
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Update cover image if found
|
||||||
|
cover_data = game_data.get('cover')
|
||||||
|
if cover_data and cover_data.get('image_id'):
|
||||||
|
new_cover_id = cover_data['image_id']
|
||||||
|
new_cover_url = IGDB.build_cover_url(new_cover_id, 'cover_big')
|
||||||
|
|
||||||
|
# Update database with new image ID/URL
|
||||||
|
metadata.cover_image = new_cover_id # Store the new ID
|
||||||
|
result['cover_updated'] = True
|
||||||
|
|
||||||
|
# Download the image
|
||||||
|
cover_filename = get_image_filename(new_cover_url, game.title, 'cover')
|
||||||
|
cover_path = self.config.images_path / cover_filename
|
||||||
|
|
||||||
|
if await download_image(new_cover_url, cover_path, session):
|
||||||
|
metadata.cover_image_path = cover_path
|
||||||
|
result['cover_downloaded'] = True
|
||||||
|
self.download_success_count += 1
|
||||||
|
else:
|
||||||
|
result['errors'].append(f'Failed to download cover: {new_cover_url}')
|
||||||
|
|
||||||
|
# Update screenshot if found
|
||||||
|
artworks = game_data.get('artworks', [])
|
||||||
|
if artworks and len(artworks) > 0 and artworks[0].get('image_id'):
|
||||||
|
new_screenshot_id = artworks[0]['image_id']
|
||||||
|
new_screenshot_url = IGDB.build_cover_url(new_screenshot_id, 'screenshot_med')
|
||||||
|
|
||||||
|
# Update database with new image ID/URL
|
||||||
|
metadata.screenshot = new_screenshot_id # Store the new ID
|
||||||
|
result['screenshot_updated'] = True
|
||||||
|
|
||||||
|
# Download the image
|
||||||
|
screenshot_filename = get_image_filename(new_screenshot_url, game.title, 'screenshot')
|
||||||
|
screenshot_path = self.config.images_path / screenshot_filename
|
||||||
|
|
||||||
|
if await download_image(new_screenshot_url, screenshot_path, session):
|
||||||
|
metadata.screenshot_path = screenshot_path
|
||||||
|
result['screenshot_downloaded'] = True
|
||||||
|
self.download_success_count += 1
|
||||||
|
else:
|
||||||
|
result['errors'].append(f'Failed to download screenshot: {new_screenshot_url}')
|
||||||
|
|
||||||
|
if result['cover_updated'] or result['screenshot_updated']:
|
||||||
|
self.refreshed_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result['errors'].append(f'API error: {str(e)}')
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def process_batch(self, games: List[Game_table], batch_size: int = 20):
|
||||||
|
"""Process games in batches with rate limiting."""
|
||||||
|
semaphore = asyncio.Semaphore(2) # Lower concurrency for API calls
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
||||||
|
async def refresh_with_semaphore(game):
|
||||||
|
async with semaphore:
|
||||||
|
await asyncio.sleep(0.5) # Rate limiting - be respectful to IGDB API
|
||||||
|
return await self.refresh_game_metadata(game, session)
|
||||||
|
|
||||||
|
# Process in smaller batches to avoid overwhelming the API
|
||||||
|
for i in range(0, len(games), batch_size):
|
||||||
|
batch = games[i:i + batch_size]
|
||||||
|
print(f"\nProcessing batch {i//batch_size + 1} ({len(batch)} games)...")
|
||||||
|
|
||||||
|
# Process this batch
|
||||||
|
tasks = [refresh_with_semaphore(game) for game in batch]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# Update database with results - need to reattach objects to new session
|
||||||
|
with Session(self.engine) as db_session:
|
||||||
|
for game, result in zip(batch, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
print(f" ✗ {game.title}: Exception - {str(result)}")
|
||||||
|
self.failed_refreshes.append(f"{game.title}: {str(result)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Reattach the game object to this session
|
||||||
|
db_session.merge(game)
|
||||||
|
|
||||||
|
# Update the game's metadata directly
|
||||||
|
if result.get('cover_path'):
|
||||||
|
game.metadata_obj.cover_image_path = result['cover_path']
|
||||||
|
if result.get('screenshot_path'):
|
||||||
|
game.metadata_obj.screenshot_path = result['screenshot_path']
|
||||||
|
|
||||||
|
# Show progress
|
||||||
|
status = []
|
||||||
|
if result['api_success']:
|
||||||
|
if result['cover_updated']:
|
||||||
|
status.append('cover updated' + (' + downloaded' if result['cover_downloaded'] else ''))
|
||||||
|
if result['screenshot_updated']:
|
||||||
|
status.append('screenshot updated' + (' + downloaded' if result['screenshot_downloaded'] else ''))
|
||||||
|
|
||||||
|
if result['errors']:
|
||||||
|
error_summary = '; '.join(result['errors'][:2]) # Show first 2 errors
|
||||||
|
if len(result['errors']) > 2:
|
||||||
|
error_summary += f' (+{len(result["errors"])-2} more)'
|
||||||
|
status.append(f"errors: {error_summary}")
|
||||||
|
self.failed_refreshes.append(f"{game.title}: {error_summary}")
|
||||||
|
|
||||||
|
print(f" {game.title}: {', '.join(status) if status else 'no changes'}")
|
||||||
|
|
||||||
|
# Commit batch
|
||||||
|
db_session.commit()
|
||||||
|
print(f" Batch committed to database")
|
||||||
|
|
||||||
|
async def run(self, limit: Optional[int] = None, dry_run: bool = False):
|
||||||
|
"""Run the cover refresh process."""
|
||||||
|
print("🔄 ROM Cover Refresh Tool")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Get games with old image IDs
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
# Use raw SQL for SQLite REGEXP (since SQLite's REGEXP isn't standard)
|
||||||
|
stmt = (
|
||||||
|
select(Game_table)
|
||||||
|
.join(Metadata_table)
|
||||||
|
.options(selectinload(Game_table.metadata_obj))
|
||||||
|
.where(
|
||||||
|
# Check if cover_image is purely numeric (old format)
|
||||||
|
Metadata_table.cover_image.isnot(None) &
|
||||||
|
~Metadata_table.cover_image.op('GLOB')('*[a-zA-Z]*') # No letters
|
||||||
|
)
|
||||||
|
.order_by(Game_table.title)
|
||||||
|
)
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
stmt = stmt.limit(limit)
|
||||||
|
|
||||||
|
games = session.scalars(stmt).all()
|
||||||
|
print(f"Found {len(games)} games with old numeric image IDs")
|
||||||
|
|
||||||
|
if not games:
|
||||||
|
print("✅ No games need cover refresh!")
|
||||||
|
return
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print("\n🔍 DRY RUN - showing first 10 games that would be processed:")
|
||||||
|
for i, game in enumerate(games[:10]):
|
||||||
|
metadata = game.metadata_obj
|
||||||
|
print(f" {i+1}. {game.title}")
|
||||||
|
print(f" Current cover ID: {metadata.cover_image}")
|
||||||
|
if metadata.screenshot:
|
||||||
|
print(f" Current screenshot ID: {metadata.screenshot}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show warning about API usage
|
||||||
|
print(f"\n⚠️ This will make {len(games)} IGDB API calls.")
|
||||||
|
print(" Be mindful of rate limits and API quotas.")
|
||||||
|
|
||||||
|
proceed = input(f"\nRefresh metadata for {len(games)} games? [y/N]: ").strip().lower()
|
||||||
|
if proceed != 'y':
|
||||||
|
print("Cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process the games
|
||||||
|
await self.process_batch(games)
|
||||||
|
|
||||||
|
# Show final results
|
||||||
|
print(f"\n✅ Refresh Complete!")
|
||||||
|
print(f" Games with updated metadata: {self.refreshed_count}")
|
||||||
|
print(f" Images successfully downloaded: {self.download_success_count}")
|
||||||
|
print(f" Failed refreshes: {len(self.failed_refreshes)}")
|
||||||
|
|
||||||
|
if self.failed_refreshes:
|
||||||
|
print(f"\nFailed Refreshes (first 10):")
|
||||||
|
for failure in self.failed_refreshes[:10]:
|
||||||
|
print(f" - {failure}")
|
||||||
|
if len(self.failed_refreshes) > 10:
|
||||||
|
print(f" ... and {len(self.failed_refreshes) - 10} more")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Refresh cover metadata for games with old image IDs")
|
||||||
|
parser.add_argument('--limit', type=int, help='Limit number of games to process')
|
||||||
|
parser.add_argument('--dry-run', action='store_true', help='Show what would be done without processing')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
manager = CoverRefreshManager()
|
||||||
|
await manager.run(limit=args.limit, dry_run=args.dry_run)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
1071
src/webapp.py
Executable file
1071
src/webapp.py
Executable file
File diff suppressed because it is too large
Load Diff
1390
templates/admin.html
Normal file
1390
templates/admin.html
Normal file
File diff suppressed because it is too large
Load Diff
180
templates/admin_users.html
Normal file
180
templates/admin_users.html
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Manage Users - DosVault{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold mb-2">Manage Users</h1>
|
||||||
|
<p class="text-gray-400">Create and manage user accounts</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="showCreateUser()" class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded">
|
||||||
|
Create New User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">User</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Role</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Status</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Created</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-700">
|
||||||
|
{% for user in users %}
|
||||||
|
<tr class="hover:bg-gray-700">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-white">{{ user.username }}</p>
|
||||||
|
<p class="text-sm text-gray-400">{{ user.email }}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="px-2 py-1 rounded text-xs
|
||||||
|
{% if user.role == 'super' %}bg-red-600
|
||||||
|
{% elif user.role == 'normal' %}bg-blue-600
|
||||||
|
{% else %}bg-yellow-600{% endif %}">
|
||||||
|
{{ user.role.upper() }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="px-2 py-1 rounded text-xs
|
||||||
|
{% if user.is_active %}bg-green-600{% else %}bg-gray-600{% endif %}">
|
||||||
|
{% if user.is_active %}ACTIVE{% else %}INACTIVE{% endif %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||||
|
{{ user.created_at.strftime('%Y-%m-%d') if user.created_at else 'N/A' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
{% if user.id != current_user.id %}
|
||||||
|
<button onclick="toggleUserActive({{ user.id }})"
|
||||||
|
class="{% if user.is_active %}text-red-400 hover:text-red-300{% else %}text-green-400 hover:text-green-300{% endif %}">
|
||||||
|
{% if user.is_active %}Deactivate{% else %}Activate{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div class="mt-8 flex justify-center">
|
||||||
|
<nav class="flex items-center space-x-2">
|
||||||
|
{% if current_page > 1 %}
|
||||||
|
<a href="?page={{ current_page - 1 }}"
|
||||||
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for page_num in range(1, total_pages + 1) %}
|
||||||
|
{% if page_num == current_page %}
|
||||||
|
<span class="px-3 py-2 bg-blue-600 rounded text-sm">{{ page_num }}</span>
|
||||||
|
{% elif page_num <= current_page + 2 and page_num >= current_page - 2 %}
|
||||||
|
<a href="?page={{ page_num }}"
|
||||||
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||||
|
{{ page_num }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if current_page < total_pages %}
|
||||||
|
<a href="?page={{ current_page + 1 }}"
|
||||||
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Create User Modal -->
|
||||||
|
<div id="createUserModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-gray-800 border-gray-700">
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-white">Create New User</h3>
|
||||||
|
<button onclick="hideCreateUser()" class="text-gray-400 hover:text-white">×</button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<input type="text" name="username" placeholder="Username" required
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="email" name="email" placeholder="Email" required
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="password" name="password" placeholder="Password" required
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<select name="role" required
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="">Select Role</option>
|
||||||
|
<option value="demo">Demo User</option>
|
||||||
|
<option value="normal">Normal User</option>
|
||||||
|
<option value="super">Super User</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button type="button" onclick="hideCreateUser()" class="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded-md">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-md">
|
||||||
|
Create User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showCreateUser() {
|
||||||
|
document.getElementById('createUserModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideCreateUser() {
|
||||||
|
document.getElementById('createUserModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleUserActive(userId) {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/admin/users/${userId}/toggle-active`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to update user status');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling user status:', error);
|
||||||
|
alert('Failed to update user status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
545
templates/base.html
Normal file
545
templates/base.html
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}DosVault{% endblock %}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
/* Default Dark Theme */
|
||||||
|
--primary-bg: #111827;
|
||||||
|
--secondary-bg: #1f2937;
|
||||||
|
--tertiary-bg: #374151;
|
||||||
|
--accent-bg: #2563eb;
|
||||||
|
--accent-hover: #1d4ed8;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #9ca3af;
|
||||||
|
--text-accent: #60a5fa;
|
||||||
|
--border-color: #4b5563;
|
||||||
|
--success-color: #059669;
|
||||||
|
--warning-color: #d97706;
|
||||||
|
--danger-color: #dc2626;
|
||||||
|
--gradient-from: #1f2937;
|
||||||
|
--gradient-to: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tokyo Night Theme */
|
||||||
|
.theme-tokyo-night {
|
||||||
|
--primary-bg: #1a1b26;
|
||||||
|
--secondary-bg: #24283b;
|
||||||
|
--tertiary-bg: #414868;
|
||||||
|
--accent-bg: #7aa2f7;
|
||||||
|
--accent-hover: #565f89;
|
||||||
|
--text-primary: #c0caf5;
|
||||||
|
--text-secondary: #9aa5ce;
|
||||||
|
--text-accent: #7aa2f7;
|
||||||
|
--border-color: #414868;
|
||||||
|
--success-color: #9ece6a;
|
||||||
|
--warning-color: #e0af68;
|
||||||
|
--danger-color: #f7768e;
|
||||||
|
--gradient-from: #24283b;
|
||||||
|
--gradient-to: #1a1b26;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cyberpunk Theme */
|
||||||
|
.theme-cyberpunk {
|
||||||
|
--primary-bg: #0d0208;
|
||||||
|
--secondary-bg: #1a0e1a;
|
||||||
|
--tertiary-bg: #2d1b2e;
|
||||||
|
--accent-bg: #ff006e;
|
||||||
|
--accent-hover: #d90856;
|
||||||
|
--text-primary: #00f5ff;
|
||||||
|
--text-secondary: #c77dff;
|
||||||
|
--text-accent: #ff006e;
|
||||||
|
--border-color: #7209b7;
|
||||||
|
--success-color: #39ff14;
|
||||||
|
--warning-color: #ffbe0b;
|
||||||
|
--danger-color: #ff1744;
|
||||||
|
--gradient-from: #1a0e1a;
|
||||||
|
--gradient-to: #0d0208;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ultra Retro Theme */
|
||||||
|
.theme-ultra-retro {
|
||||||
|
--primary-bg: #000000;
|
||||||
|
--secondary-bg: #001100;
|
||||||
|
--tertiary-bg: #003300;
|
||||||
|
--accent-bg: #00ff00;
|
||||||
|
--accent-hover: #00cc00;
|
||||||
|
--text-primary: #00ff00;
|
||||||
|
--text-secondary: #00cc00;
|
||||||
|
--text-accent: #00ff00;
|
||||||
|
--border-color: #005500;
|
||||||
|
--success-color: #00ff00;
|
||||||
|
--warning-color: #ffff00;
|
||||||
|
--danger-color: #ff0000;
|
||||||
|
--gradient-from: #001100;
|
||||||
|
--gradient-to: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ocean Theme */
|
||||||
|
.theme-ocean {
|
||||||
|
--primary-bg: #0f172a;
|
||||||
|
--secondary-bg: #1e293b;
|
||||||
|
--tertiary-bg: #334155;
|
||||||
|
--accent-bg: #0ea5e9;
|
||||||
|
--accent-hover: #0284c7;
|
||||||
|
--text-primary: #f1f5f9;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-accent: #38bdf8;
|
||||||
|
--border-color: #475569;
|
||||||
|
--success-color: #10b981;
|
||||||
|
--warning-color: #f59e0b;
|
||||||
|
--danger-color: #ef4444;
|
||||||
|
--gradient-from: #1e293b;
|
||||||
|
--gradient-to: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sunset Theme */
|
||||||
|
.theme-sunset {
|
||||||
|
--primary-bg: #451a03;
|
||||||
|
--secondary-bg: #7c2d12;
|
||||||
|
--tertiary-bg: #9a3412;
|
||||||
|
--accent-bg: #ea580c;
|
||||||
|
--accent-hover: #c2410c;
|
||||||
|
--text-primary: #fed7aa;
|
||||||
|
--text-secondary: #fdba74;
|
||||||
|
--text-accent: #fb923c;
|
||||||
|
--border-color: #9a3412;
|
||||||
|
--success-color: #22c55e;
|
||||||
|
--warning-color: #eab308;
|
||||||
|
--danger-color: #ef4444;
|
||||||
|
--gradient-from: #7c2d12;
|
||||||
|
--gradient-to: #451a03;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply CSS custom properties */
|
||||||
|
body {
|
||||||
|
background-color: var(--primary-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.bg-primary { background-color: var(--primary-bg); }
|
||||||
|
.bg-secondary { background-color: var(--secondary-bg); }
|
||||||
|
.bg-tertiary { background-color: var(--tertiary-bg); }
|
||||||
|
.bg-accent { background-color: var(--accent-bg); }
|
||||||
|
.bg-accent:hover { background-color: var(--accent-hover); }
|
||||||
|
.text-primary { color: var(--text-primary); }
|
||||||
|
.text-secondary { color: var(--text-secondary); }
|
||||||
|
.text-accent { color: var(--text-accent); }
|
||||||
|
.border-theme { border-color: var(--border-color); }
|
||||||
|
.bg-gradient-theme { background: linear-gradient(to bottom right, var(--gradient-from), var(--gradient-to)); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-primary text-primary min-h-screen">
|
||||||
|
<nav class="bg-secondary border-b border-theme">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between h-16">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<a href="/" class="flex items-center space-x-3 text-xl font-bold text-blue-400 hover:text-blue-300 transition-colors">
|
||||||
|
<svg class="w-8 h-8" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Vault door -->
|
||||||
|
<circle cx="16" cy="16" r="14" fill="currentColor" opacity="0.1"/>
|
||||||
|
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
<circle cx="16" cy="16" r="4" fill="currentColor" opacity="0.3"/>
|
||||||
|
|
||||||
|
<!-- DOS-style pixels -->
|
||||||
|
<rect x="6" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||||
|
<rect x="10" y="6" width="2" height="2" fill="currentColor" opacity="0.8"/>
|
||||||
|
<rect x="20" y="6" width="2" height="2" fill="currentColor" opacity="0.8"/>
|
||||||
|
<rect x="24" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||||
|
<rect x="6" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||||
|
<rect x="24" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||||
|
|
||||||
|
<!-- Handle -->
|
||||||
|
<rect x="20" y="14" width="6" height="4" rx="2" fill="currentColor" opacity="0.7"/>
|
||||||
|
<circle cx="24" cy="16" r="1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<span>DosVault</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop Navigation -->
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<div class="ml-10 flex items-baseline space-x-4">
|
||||||
|
<a href="/" class="hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium">
|
||||||
|
Browse ROMs
|
||||||
|
</a>
|
||||||
|
{% if current_user and current_user.role != "demo" %}
|
||||||
|
<a href="/favorites" class="hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium">
|
||||||
|
My Favorites
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="toggleGenresSidebar()" class="hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium">
|
||||||
|
Browse Genres
|
||||||
|
</button>
|
||||||
|
{% if current_user and current_user.role == "super" %}
|
||||||
|
<a href="/admin" class="hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium">
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop User Info -->
|
||||||
|
<div class="hidden md:flex items-center space-x-4">
|
||||||
|
<!-- Theme Picker -->
|
||||||
|
<div class="relative">
|
||||||
|
<button onclick="toggleThemeMenu()" class="bg-gray-700 hover:bg-gray-600 p-2 rounded text-sm">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4h4a2 2 0 002-2V5z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="theme-menu" class="hidden absolute right-0 mt-2 w-48 bg-gray-800 rounded-md shadow-lg z-50 border border-gray-700">
|
||||||
|
<div class="py-1">
|
||||||
|
<button onclick="setTheme('default')" class="flex items-center w-full px-4 py-2 text-sm text-white hover:bg-gray-700">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-gray-700 mr-3"></div>
|
||||||
|
Default Dark
|
||||||
|
</button>
|
||||||
|
<button onclick="setTheme('tokyo-night')" class="flex items-center w-full px-4 py-2 text-sm text-white hover:bg-gray-700">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-purple-600 mr-3"></div>
|
||||||
|
Tokyo Night
|
||||||
|
</button>
|
||||||
|
<button onclick="setTheme('cyberpunk')" class="flex items-center w-full px-4 py-2 text-sm text-white hover:bg-gray-700">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-pink-500 mr-3"></div>
|
||||||
|
Cyberpunk
|
||||||
|
</button>
|
||||||
|
<button onclick="setTheme('ultra-retro')" class="flex items-center w-full px-4 py-2 text-sm text-white hover:bg-gray-700">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-green-500 mr-3"></div>
|
||||||
|
Ultra Retro
|
||||||
|
</button>
|
||||||
|
<button onclick="setTheme('ocean')" class="flex items-center w-full px-4 py-2 text-sm text-white hover:bg-gray-700">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-blue-500 mr-3"></div>
|
||||||
|
Ocean
|
||||||
|
</button>
|
||||||
|
<button onclick="setTheme('sunset')" class="flex items-center w-full px-4 py-2 text-sm text-white hover:bg-gray-700">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-orange-500 mr-3"></div>
|
||||||
|
Sunset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_user %}
|
||||||
|
<span class="text-sm">
|
||||||
|
Welcome, {{ current_user.username }}
|
||||||
|
{% if current_user.role == "demo" %}
|
||||||
|
<span class="bg-yellow-600 px-2 py-1 rounded text-xs">DEMO</span>
|
||||||
|
{% elif current_user.role == "super" %}
|
||||||
|
<span class="bg-red-600 px-2 py-1 rounded text-xs">ADMIN</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<button onclick="logout()" class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded text-sm">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button onclick="showLogin()" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<span class="bg-yellow-600 px-2 py-1 rounded text-xs">DEMO MODE</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile menu button -->
|
||||||
|
<div class="md:hidden">
|
||||||
|
<button onclick="toggleMobileMenu()" class="bg-gray-700 inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white">
|
||||||
|
<svg id="hamburger-icon" class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
<svg id="close-icon" class="hidden h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile menu -->
|
||||||
|
<div id="mobile-menu" class="md:hidden hidden">
|
||||||
|
<div class="px-2 pt-2 pb-3 space-y-1 sm:px-3">
|
||||||
|
<a href="/" class="hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium">
|
||||||
|
Browse ROMs
|
||||||
|
</a>
|
||||||
|
{% if current_user and current_user.role != "demo" %}
|
||||||
|
<a href="/favorites" class="hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium">
|
||||||
|
My Favorites
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="toggleGenresSidebar()" class="hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium w-full text-left">
|
||||||
|
Browse Genres
|
||||||
|
</button>
|
||||||
|
{% if current_user and current_user.role == "super" %}
|
||||||
|
<a href="/admin" class="hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium">
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile User Info -->
|
||||||
|
<div class="pt-4 pb-3 border-t border-gray-700">
|
||||||
|
<div class="px-5">
|
||||||
|
<!-- Mobile Theme Picker -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-sm text-gray-400 mb-2">Theme</p>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<button onclick="setTheme('default')" class="flex flex-col items-center p-2 bg-gray-700 hover:bg-gray-600 rounded text-xs">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-gray-700 mb-1"></div>
|
||||||
|
Default
|
||||||
|
</button>
|
||||||
|
<button onclick="setTheme('tokyo-night')" class="flex flex-col items-center p-2 bg-gray-700 hover:bg-gray-600 rounded text-xs">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-purple-600 mb-1"></div>
|
||||||
|
Tokyo
|
||||||
|
</button>
|
||||||
|
<button onclick="setTheme('cyberpunk')" class="flex flex-col items-center p-2 bg-gray-700 hover:bg-gray-600 rounded text-xs">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-pink-500 mb-1"></div>
|
||||||
|
Cyber
|
||||||
|
</button>
|
||||||
|
<button onclick="setTheme('ultra-retro')" class="flex flex-col items-center p-2 bg-gray-700 hover:bg-gray-600 rounded text-xs">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-green-500 mb-1"></div>
|
||||||
|
Retro
|
||||||
|
</button>
|
||||||
|
<button onclick="setTheme('ocean')" class="flex flex-col items-center p-2 bg-gray-700 hover:bg-gray-600 rounded text-xs">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-blue-500 mb-1"></div>
|
||||||
|
Ocean
|
||||||
|
</button>
|
||||||
|
<button onclick="setTheme('sunset')" class="flex flex-col items-center p-2 bg-gray-700 hover:bg-gray-600 rounded text-xs">
|
||||||
|
<div class="w-4 h-4 rounded-full bg-orange-500 mb-1"></div>
|
||||||
|
Sunset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_user %}
|
||||||
|
<div class="text-base font-medium text-white">{{ current_user.username }}</div>
|
||||||
|
<div class="text-sm text-gray-400 mb-3">
|
||||||
|
{% if current_user.role == "demo" %}
|
||||||
|
<span class="bg-yellow-600 px-2 py-1 rounded text-xs">DEMO USER</span>
|
||||||
|
{% elif current_user.role == "super" %}
|
||||||
|
<span class="bg-red-600 px-2 py-1 rounded text-xs">ADMIN</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="bg-green-600 px-2 py-1 rounded text-xs">USER</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button onclick="logout()" class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded text-sm w-full">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button onclick="showLogin()" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm w-full mb-2">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<span class="bg-yellow-600 px-2 py-1 rounded text-xs">DEMO MODE</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Genres Sidebar -->
|
||||||
|
<div id="genresSidebar" class="fixed inset-y-0 left-0 w-80 bg-gray-800 border-r border-gray-700 transform -translate-x-full transition-transform duration-300 ease-in-out z-50">
|
||||||
|
<div class="h-full flex flex-col p-4">
|
||||||
|
<div class="flex justify-between items-center mb-4 flex-shrink-0">
|
||||||
|
<h3 class="text-lg font-medium text-white">Browse by Genre</h3>
|
||||||
|
<button onclick="toggleGenresSidebar()" class="text-gray-400 hover:text-white">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="genresContainer" class="space-y-2 flex-1 overflow-y-auto">
|
||||||
|
<p class="text-gray-400 text-sm">Loading genres...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlay -->
|
||||||
|
<div id="genresOverlay" class="fixed inset-0 bg-black bg-opacity-50 z-40 hidden" onclick="toggleGenresSidebar()"></div>
|
||||||
|
|
||||||
|
<main class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Login Modal -->
|
||||||
|
<div id="loginModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-gray-800 border-gray-700">
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-white">Login</h3>
|
||||||
|
<button onclick="hideLogin()" class="text-gray-400 hover:text-white">×</button>
|
||||||
|
</div>
|
||||||
|
<form onsubmit="handleLogin(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<input type="text" id="username" placeholder="Username" required
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="password" id="password" placeholder="Password" required
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button type="button" onclick="hideLogin()" class="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded-md">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-md">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let authToken = localStorage.getItem('authToken');
|
||||||
|
|
||||||
|
function toggleMobileMenu() {
|
||||||
|
const mobileMenu = document.getElementById('mobile-menu');
|
||||||
|
const hamburgerIcon = document.getElementById('hamburger-icon');
|
||||||
|
const closeIcon = document.getElementById('close-icon');
|
||||||
|
|
||||||
|
mobileMenu.classList.toggle('hidden');
|
||||||
|
hamburgerIcon.classList.toggle('hidden');
|
||||||
|
closeIcon.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleThemeMenu() {
|
||||||
|
const themeMenu = document.getElementById('theme-menu');
|
||||||
|
themeMenu.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(themeName) {
|
||||||
|
// Remove all existing theme classes
|
||||||
|
document.body.classList.remove('theme-tokyo-night', 'theme-cyberpunk', 'theme-ultra-retro', 'theme-ocean', 'theme-sunset');
|
||||||
|
|
||||||
|
// Add the selected theme class
|
||||||
|
if (themeName !== 'default') {
|
||||||
|
document.body.classList.add(`theme-${themeName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save theme preference
|
||||||
|
localStorage.setItem('dosvault-theme', themeName);
|
||||||
|
|
||||||
|
// Hide theme menu
|
||||||
|
document.getElementById('theme-menu').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved theme on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const savedTheme = localStorage.getItem('dosvault-theme');
|
||||||
|
if (savedTheme && savedTheme !== 'default') {
|
||||||
|
document.body.classList.add(`theme-${savedTheme}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close theme menu when clicking outside
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
const themeMenu = document.getElementById('theme-menu');
|
||||||
|
const themeButton = event.target.closest('[onclick="toggleThemeMenu()"]');
|
||||||
|
|
||||||
|
if (!themeButton && !themeMenu.contains(event.target)) {
|
||||||
|
themeMenu.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showLogin() {
|
||||||
|
document.getElementById('loginModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLogin() {
|
||||||
|
document.getElementById('loginModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('username', username);
|
||||||
|
formData.append('password', password);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
localStorage.setItem('authToken', data.access_token);
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Login failed. Please check your credentials.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
alert('Login failed. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch('/logout', { method: 'POST' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
}
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set auth header if token exists
|
||||||
|
if (authToken) {
|
||||||
|
fetch.defaults = {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${authToken}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let genresLoaded = false;
|
||||||
|
|
||||||
|
function toggleGenresSidebar() {
|
||||||
|
const sidebar = document.getElementById('genresSidebar');
|
||||||
|
const overlay = document.getElementById('genresOverlay');
|
||||||
|
const isOpen = !sidebar.classList.contains('-translate-x-full');
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
sidebar.classList.add('-translate-x-full');
|
||||||
|
overlay.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
sidebar.classList.remove('-translate-x-full');
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
if (!genresLoaded) {
|
||||||
|
loadGenres();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGenres() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/genres');
|
||||||
|
const genres = await response.json();
|
||||||
|
const container = document.getElementById('genresContainer');
|
||||||
|
|
||||||
|
if (genres.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-gray-400 text-sm">No genres found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = genres
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.map(genre => `
|
||||||
|
<a href="/browse/genres/${encodeURIComponent(genre.name)}"
|
||||||
|
class="block p-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-white">
|
||||||
|
${genre.name} <span class="text-gray-400">(${genre.count})</span>
|
||||||
|
</a>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
genresLoaded = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading genres:', error);
|
||||||
|
document.getElementById('genresContainer').innerHTML =
|
||||||
|
'<p class="text-red-400 text-sm">Error loading genres</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
76
templates/edit_game.html
Normal file
76
templates/edit_game.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Edit Game - DosVault{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-3xl font-bold mb-2">Edit Game Metadata</h1>
|
||||||
|
<p class="text-gray-400">Update the information for: {{ game.title }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<form method="POST" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="title" class="block text-sm font-medium text-gray-300 mb-2">Title</label>
|
||||||
|
<input type="text" id="title" name="title"
|
||||||
|
value="{{ game.metadata_obj.title or game.title }}"
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="description" class="block text-sm font-medium text-gray-300 mb-2">Description</label>
|
||||||
|
<textarea id="description" name="description" rows="4"
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Enter game description...">{{ game.metadata_obj.description or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="year" class="block text-sm font-medium text-gray-300 mb-2">Year</label>
|
||||||
|
<input type="number" id="year" name="year"
|
||||||
|
value="{{ game.metadata_obj.year or '' }}"
|
||||||
|
min="1970" max="2030"
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="players" class="block text-sm font-medium text-gray-300 mb-2">Players</label>
|
||||||
|
<input type="number" id="players" name="players"
|
||||||
|
value="{{ game.metadata_obj.players or '' }}"
|
||||||
|
min="1" max="16"
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="developer" class="block text-sm font-medium text-gray-300 mb-2">Developer</label>
|
||||||
|
<input type="text" id="developer" name="developer"
|
||||||
|
value="{{ game.metadata_obj.developer or '' }}"
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="publisher" class="block text-sm font-medium text-gray-300 mb-2">Publisher</label>
|
||||||
|
<input type="text" id="publisher" name="publisher"
|
||||||
|
value="{{ game.metadata_obj.publisher or '' }}"
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between pt-6">
|
||||||
|
<a href="/games/{{ game.id }}"
|
||||||
|
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 rounded-md text-white transition-colors">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-md text-white transition-colors">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
144
templates/favorites.html
Normal file
144
templates/favorites.html
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}My Favorites - DosVault{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold mb-2">My Favorites</h1>
|
||||||
|
<p class="text-gray-400">Your personally selected ROM collection</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if games %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{% for game in games %}
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-gray-600 transition-colors">
|
||||||
|
<div class="flex justify-between items-start mb-3">
|
||||||
|
<h3 class="text-lg font-semibold text-blue-400 truncate">{{ game.metadata_obj.title or game.title }}</h3>
|
||||||
|
<button onclick="toggleFavorite({{ game.id }})"
|
||||||
|
class="text-red-600 hover:text-red-500 text-xl"
|
||||||
|
id="favorite-{{ game.id }}">
|
||||||
|
♥
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if game.metadata_obj %}
|
||||||
|
<div class="space-y-2 text-sm text-gray-300">
|
||||||
|
{% if game.metadata_obj.year %}
|
||||||
|
<p><span class="text-gray-400">Year:</span> {{ game.metadata_obj.year }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if game.metadata_obj.developer %}
|
||||||
|
<p><span class="text-gray-400">Developer:</span> {{ game.metadata_obj.developer }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if game.metadata_obj.description %}
|
||||||
|
<p class="text-xs text-gray-400 line-clamp-3">{{ game.metadata_obj.description[:100] }}{% if game.metadata_obj.description|length > 100 %}...{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-between items-center">
|
||||||
|
<a href="/games/{{ game.id }}" class="text-blue-400 hover:text-blue-300 text-sm underline">
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
<button onclick="downloadGame({{ game.id }})"
|
||||||
|
class="bg-green-600 hover:bg-green-700 px-3 py-1 rounded text-sm">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div class="mt-12 flex justify-center">
|
||||||
|
<nav class="flex items-center space-x-2">
|
||||||
|
{% if current_page > 1 %}
|
||||||
|
<a href="?page={{ current_page - 1 }}"
|
||||||
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for page_num in range(1, total_pages + 1) %}
|
||||||
|
{% if page_num == current_page %}
|
||||||
|
<span class="px-3 py-2 bg-blue-600 rounded text-sm">{{ page_num }}</span>
|
||||||
|
{% elif page_num <= current_page + 2 and page_num >= current_page - 2 %}
|
||||||
|
<a href="?page={{ page_num }}"
|
||||||
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||||
|
{{ page_num }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if current_page < total_pages %}
|
||||||
|
<a href="?page={{ current_page + 1 }}"
|
||||||
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="text-6xl mb-4">💔</div>
|
||||||
|
<h2 class="text-2xl font-bold mb-2">No favorites yet</h2>
|
||||||
|
<p class="text-gray-400 mb-6">Start browsing and add games to your favorites collection!</p>
|
||||||
|
<a href="/" class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg text-white">
|
||||||
|
Browse ROMs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function toggleFavorite(gameId) {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/games/${gameId}/favorite`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Remove the game from favorites view
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling favorite:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadGame(gameId) {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/download/${gameId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.style.display = 'none';
|
||||||
|
a.href = url;
|
||||||
|
a.download = response.headers.get('Content-Disposition')?.split('filename=')[1] || 'game.zip';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
288
templates/game_detail.html
Normal file
288
templates/game_detail.html
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ game.metadata_obj.title or game.title }} - DosVault{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="mb-6">
|
||||||
|
<nav class="text-sm text-gray-400 mb-4">
|
||||||
|
<a href="/" class="hover:text-white">Browse ROMs</a>
|
||||||
|
<span class="mx-2">/</span>
|
||||||
|
<span class="text-white">{{ game.metadata_obj.title or game.title }}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold mb-2">{{ game.metadata_obj.title or game.title }}</h1>
|
||||||
|
<p class="text-gray-400">{{ game.path.name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
{% if not is_demo %}
|
||||||
|
<button onclick="toggleFavorite({{ game.id }})"
|
||||||
|
class="text-red-400 hover:text-red-300 text-2xl"
|
||||||
|
id="favorite-{{ game.id }}">
|
||||||
|
{% if is_favorite %}♥{% else %}♡{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_user and current_user.role == "super" %}
|
||||||
|
<a href="/admin/games/{{ game.id }}/edit"
|
||||||
|
class="bg-yellow-600 hover:bg-yellow-700 px-4 py-2 rounded text-sm">
|
||||||
|
Edit Metadata
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_download %}
|
||||||
|
<button onclick="downloadGame({{ game.id }})"
|
||||||
|
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded">
|
||||||
|
Download ROM
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="bg-gray-600 px-4 py-2 rounded cursor-not-allowed">
|
||||||
|
{% if current_user %}Demo Mode - No Downloads{% else %}Login to Download{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
{% if game.metadata_obj and game.metadata_obj.description %}
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 mb-6">
|
||||||
|
<h2 class="text-xl font-bold mb-3">Description</h2>
|
||||||
|
<p class="text-gray-300 leading-relaxed">{{ game.metadata_obj.description }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Game Information</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% if game.metadata_obj %}
|
||||||
|
{% if game.metadata_obj.year %}
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400 text-sm">Release Year</p>
|
||||||
|
<p class="font-medium">{{ game.metadata_obj.year }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if game.metadata_obj.developer %}
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400 text-sm">Developer</p>
|
||||||
|
<p class="font-medium">{{ game.metadata_obj.developer }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if game.metadata_obj.publisher %}
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400 text-sm">Publisher</p>
|
||||||
|
<p class="font-medium">{{ game.metadata_obj.publisher }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if game.metadata_obj.players %}
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400 text-sm">Players</p>
|
||||||
|
<p class="font-medium">{{ game.metadata_obj.players }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if game.metadata_obj.genre %}
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<p class="text-gray-400 text-sm mb-2">Genres</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for genre in game.metadata_obj.genre %}
|
||||||
|
<a href="/browse/genres/{{ genre.name | urlencode }}" class="bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded text-sm transition-colors">{{ genre.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if game.metadata_obj.tags %}
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<p class="text-gray-400 text-sm mb-2">Tags</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for tag in game.metadata_obj.tags %}
|
||||||
|
<span class="bg-gray-600 px-2 py-1 rounded text-sm">{{ tag.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<p class="text-gray-400 text-sm">File Path</p>
|
||||||
|
<p class="font-mono text-sm bg-gray-700 p-2 rounded">{{ game.path }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
{% if game.metadata_obj and (game.metadata_obj.cover_image_path or game.metadata_obj.cover_image) %}
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-bold mb-3">Cover Art</h3>
|
||||||
|
<img {% if game.metadata_obj.cover_image_path %}src="/images/{{ game.metadata_obj.cover_image_path.name }}"{% else %}src="{{ game.metadata_obj.cover_image }}"{% endif %}
|
||||||
|
alt="{{ game.metadata_obj.title or game.title }} cover"
|
||||||
|
class="w-full rounded">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if game.metadata_obj and (game.metadata_obj.screenshot_path or game.metadata_obj.screenshot) %}
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-bold mb-3">Screenshot</h3>
|
||||||
|
<div class="relative group cursor-pointer" onclick="openScreenshotModal('{% if game.metadata_obj.screenshot_path %}/images/{{ game.metadata_obj.screenshot_path.name }}{% else %}{{ game.metadata_obj.screenshot }}{% endif %}', '{{ game.metadata_obj.title or game.title }}')">
|
||||||
|
<img {% if game.metadata_obj.screenshot_path %}src="/images/{{ game.metadata_obj.screenshot_path.name }}"{% else %}src="{{ game.metadata_obj.screenshot }}"{% endif %}
|
||||||
|
alt="{{ game.metadata_obj.title or game.title }} screenshot"
|
||||||
|
class="w-full rounded transition-transform duration-200 group-hover:scale-105">
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 rounded transition-all duration-200 flex items-center justify-center">
|
||||||
|
<svg class="w-12 h-12 text-white opacity-0 group-hover:opacity-80 transition-opacity duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2 text-center">Click to enlarge</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not game.metadata_obj or not game.metadata_obj.description %}
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 text-center">
|
||||||
|
<div class="text-4xl mb-2">📦</div>
|
||||||
|
<p class="text-gray-400 text-sm">No detailed metadata available for this game</p>
|
||||||
|
{% if current_user and current_user.role == "super" %}
|
||||||
|
<a href="/admin/games/{{ game.id }}/edit"
|
||||||
|
class="inline-block mt-2 text-blue-400 hover:text-blue-300 text-sm underline">
|
||||||
|
Add Metadata
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshot Modal -->
|
||||||
|
<div id="screenshotModal" class="hidden fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="relative max-w-4xl max-h-full">
|
||||||
|
<button onclick="closeScreenshotModal()" class="absolute top-4 right-4 text-white hover:text-gray-300 text-3xl z-10 bg-black bg-opacity-50 rounded-full w-12 h-12 flex items-center justify-center">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<img id="screenshotModalImage" src="" alt="" class="max-w-full max-h-full rounded-lg">
|
||||||
|
<div class="absolute bottom-4 left-4 right-4 text-center">
|
||||||
|
<p id="screenshotModalTitle" class="text-white bg-black bg-opacity-50 px-4 py-2 rounded-lg"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openScreenshotModal(imageSrc, title) {
|
||||||
|
const modal = document.getElementById('screenshotModal');
|
||||||
|
const image = document.getElementById('screenshotModalImage');
|
||||||
|
const titleElement = document.getElementById('screenshotModalTitle');
|
||||||
|
|
||||||
|
image.src = imageSrc;
|
||||||
|
image.alt = title + ' screenshot';
|
||||||
|
titleElement.textContent = title + ' - Screenshot';
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
document.body.style.overflow = 'hidden'; // Prevent background scrolling
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeScreenshotModal() {
|
||||||
|
const modal = document.getElementById('screenshotModal');
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
document.body.style.overflow = 'auto'; // Restore scrolling
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside the image
|
||||||
|
document.getElementById('screenshotModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeScreenshotModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal with Escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeScreenshotModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function toggleFavorite(gameId) {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (!token) {
|
||||||
|
showLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/games/${gameId}/favorite`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const favoriteBtn = document.getElementById(`favorite-${gameId}`);
|
||||||
|
const isFavorited = favoriteBtn.textContent.trim() === '♥';
|
||||||
|
|
||||||
|
if (isFavorited) {
|
||||||
|
// Remove from favorites
|
||||||
|
favoriteBtn.textContent = '♡';
|
||||||
|
favoriteBtn.classList.remove('text-red-600');
|
||||||
|
favoriteBtn.classList.add('text-red-400');
|
||||||
|
} else {
|
||||||
|
// Add to favorites
|
||||||
|
favoriteBtn.textContent = '♥';
|
||||||
|
favoriteBtn.classList.remove('text-red-400');
|
||||||
|
favoriteBtn.classList.add('text-red-600');
|
||||||
|
}
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling favorite:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadGame(gameId) {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (!token) {
|
||||||
|
showLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/download/${gameId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.style.display = 'none';
|
||||||
|
a.href = url;
|
||||||
|
a.download = response.headers.get('Content-Disposition')?.split('filename=')[1] || 'game.zip';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
showLogin();
|
||||||
|
} else {
|
||||||
|
alert('Download failed. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download error:', error);
|
||||||
|
alert('Download failed. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
610
templates/index.html
Normal file
610
templates/index.html
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}ROM Library - DOS Frontend{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4">
|
||||||
|
<h1 class="text-3xl font-bold mb-2 sm:mb-0">ROM Library</h1>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-2">
|
||||||
|
<!-- Search Form -->
|
||||||
|
<form method="GET" class="flex gap-2">
|
||||||
|
<input type="hidden" name="page" value="1">
|
||||||
|
<input type="hidden" name="per_page" value="{{ per_page }}">
|
||||||
|
<input type="hidden" name="view" value="{{ view }}">
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" name="search" placeholder="Search games..."
|
||||||
|
value="{% if search and not search.startswith('genre:') and not search.startswith('tag:') %}{{ search }}{% endif %}"
|
||||||
|
class="w-full sm:w-64 px-4 py-2 pl-10 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 21-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls Bar -->
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||||
|
<div class="text-gray-400">
|
||||||
|
{% if is_demo %}
|
||||||
|
<span class="text-yellow-400">Demo Mode:</span> You can browse ROMs but cannot download or favorite them.
|
||||||
|
<button onclick="showLogin()" class="text-blue-400 hover:text-blue-300 underline">Login</button> for full access.
|
||||||
|
{% else %}
|
||||||
|
Showing {{ games|length }} of {{ total_games }} ROMs
|
||||||
|
{% if search %} for "{{ search }}"{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-2 sm:gap-4 items-center">
|
||||||
|
<!-- Results per page -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-gray-400 text-sm">Show:</label>
|
||||||
|
<select onchange="changePerPage(this.value)" class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-white text-sm min-w-16">
|
||||||
|
<option value="20" {% if per_page == 20 %}selected{% endif %}>20</option>
|
||||||
|
<option value="50" {% if per_page == 50 %}selected{% endif %}>50</option>
|
||||||
|
<option value="100" {% if per_page == 100 %}selected{% endif %}>100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Toggle -->
|
||||||
|
<div class="flex bg-gray-700 rounded-lg p-1">
|
||||||
|
<button onclick="changeView('grid')"
|
||||||
|
class="px-4 py-2 rounded text-sm touch-manipulation {{ 'bg-blue-600 text-white' if view == 'grid' else 'text-gray-400 hover:text-white active:bg-gray-600' }}">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-1 hidden sm:inline">Grid</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="changeView('list')"
|
||||||
|
class="px-4 py-2 rounded text-sm touch-manipulation {{ 'bg-blue-600 text-white' if view == 'list' else 'text-gray-400 hover:text-white active:bg-gray-600' }}">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-1 hidden sm:inline">List</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Games Grid View -->
|
||||||
|
{% if view == 'grid' %}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
|
{% for game in games %}
|
||||||
|
<div class="bg-gray-800 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden hover:shadow-lg transform hover:-translate-y-1 transition-all duration-200 relative group">
|
||||||
|
<!-- Clickable overlay for the card -->
|
||||||
|
<a href="/games/{{ game.id }}" class="absolute inset-0 z-10 cursor-pointer" aria-label="View {{ game.metadata_obj.title or game.title }} details"></a>
|
||||||
|
|
||||||
|
<!-- Cover Image -->
|
||||||
|
<div class="aspect-[3/4] bg-gray-900 relative overflow-hidden">
|
||||||
|
{% if game.metadata_obj and (game.metadata_obj.cover_image_path or (game.metadata_obj.cover_image and game.metadata_obj.cover_image.startswith('http'))) %}
|
||||||
|
<img data-game-id="{{ game.id }}"
|
||||||
|
{% if game.metadata_obj.cover_image_path %}src="/images/{{ game.metadata_obj.cover_image_path.name }}"{% else %}src="{{ game.metadata_obj.cover_image }}"{% endif %}
|
||||||
|
alt="{{ game.metadata_obj.title or game.title }}"
|
||||||
|
class="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||||
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
|
<div class="w-full h-full bg-gradient-to-br from-gray-800 to-gray-900 hidden items-center justify-center">
|
||||||
|
<div class="text-gray-400 text-center p-4">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2h2a2 2 0 002-2z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm font-medium">{{ (game.metadata_obj.title or game.title)[:20] }}{% if (game.metadata_obj.title or game.title)|length > 20 %}...{% endif %}</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">DOS Game</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="w-full h-full bg-gradient-theme flex items-center justify-center relative overflow-hidden">
|
||||||
|
<!-- Background Pattern -->
|
||||||
|
<div class="absolute inset-0 opacity-10">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="pixel-grid" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||||
|
<rect width="2" height="2" fill="currentColor" opacity="0.3"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#pixel-grid)"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="text-center p-3 relative z-10">
|
||||||
|
<!-- DosVault Logo -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<svg class="w-20 h-20 mx-auto text-accent" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Vault door with enhanced styling -->
|
||||||
|
<circle cx="16" cy="16" r="15" fill="currentColor" opacity="0.15" stroke="currentColor" stroke-width="0.5"/>
|
||||||
|
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.8"/>
|
||||||
|
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.6"/>
|
||||||
|
<circle cx="16" cy="16" r="4" fill="currentColor" opacity="0.4"/>
|
||||||
|
|
||||||
|
<!-- Enhanced DOS-style pixels -->
|
||||||
|
<rect x="5" y="5" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||||
|
<rect x="10" y="5" width="3" height="3" fill="currentColor" opacity="0.9"/>
|
||||||
|
<rect x="19" y="5" width="3" height="3" fill="currentColor" opacity="0.9"/>
|
||||||
|
<rect x="24" y="5" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||||
|
<rect x="5" y="24" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||||
|
<rect x="24" y="24" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||||
|
<rect x="5" y="14" width="2" height="2" fill="currentColor" opacity="0.5"/>
|
||||||
|
<rect x="25" y="14" width="2" height="2" fill="currentColor" opacity="0.5"/>
|
||||||
|
|
||||||
|
<!-- Enhanced Handle -->
|
||||||
|
<rect x="19" y="13" width="8" height="6" rx="3" fill="currentColor" opacity="0.8"/>
|
||||||
|
<circle cx="23" cy="16" r="1.5" fill="var(--primary-bg)"/>
|
||||||
|
<circle cx="23" cy="16" r="0.8" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Game Title -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<h3 class="text-accent font-bold text-sm leading-tight mb-1">
|
||||||
|
{{ (game.metadata_obj.title or game.title)[:18] }}{% if (game.metadata_obj.title or game.title)|length > 18 %}...{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div class="w-12 h-0.5 bg-accent mx-auto opacity-60"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Branding -->
|
||||||
|
<div class="text-xs text-secondary opacity-75 font-medium">
|
||||||
|
<div class="mb-1">CLASSIC DOS GAME</div>
|
||||||
|
<div class="text-accent text-[10px] font-bold tracking-wider">DOSVAULT</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decorative Corner Elements -->
|
||||||
|
<div class="absolute top-1 left-1">
|
||||||
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||||
|
<polygon points="0,0 8,0 0,8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-1 right-1">
|
||||||
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||||
|
<polygon points="8,0 8,8 0,0"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-1 left-1">
|
||||||
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||||
|
<polygon points="0,8 0,0 8,8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-1 right-1">
|
||||||
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||||
|
<polygon points="8,8 0,8 8,0"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Favorite Button -->
|
||||||
|
{% if not is_demo %}
|
||||||
|
<button onclick="event.stopPropagation(); toggleFavorite({{ game.id }})"
|
||||||
|
class="absolute top-2 right-2 w-8 h-8 bg-black bg-opacity-50 rounded-full flex items-center justify-center {% if game.id in user_favorites %}text-red-600{% else %}text-red-400{% endif %} hover:text-red-300 hover:bg-opacity-75 transition-all z-20"
|
||||||
|
id="favorite-{{ game.id }}">
|
||||||
|
{% if game.id in user_favorites %}♥{% else %}♡{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Game Info -->
|
||||||
|
<div class="p-3">
|
||||||
|
<h3 class="font-semibold text-blue-400 truncate mb-1 text-sm">
|
||||||
|
{{ game.metadata_obj.title or game.title }}
|
||||||
|
</h3>
|
||||||
|
{% if game.metadata_obj and game.metadata_obj.year %}
|
||||||
|
<p class="text-xs text-gray-400 mb-2">{{ game.metadata_obj.year }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-xs text-gray-500">Click to view details</span>
|
||||||
|
{% if not is_demo %}
|
||||||
|
<button onclick="event.stopPropagation(); downloadGame({{ game.id }})"
|
||||||
|
class="bg-green-600 hover:bg-green-700 px-2 py-1 rounded text-xs z-20 relative">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="bg-gray-600 px-2 py-1 rounded text-xs cursor-not-allowed">
|
||||||
|
Login
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Games List View -->
|
||||||
|
{% if view == 'list' %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for game in games %}
|
||||||
|
<div class="bg-gray-800 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors hover:shadow-lg transform hover:-translate-y-1 transition-all duration-200 relative group">
|
||||||
|
<!-- Clickable overlay for the card -->
|
||||||
|
<a href="/games/{{ game.id }}" class="absolute inset-0 z-10 cursor-pointer" aria-label="View {{ game.metadata_obj.title or game.title }} details"></a>
|
||||||
|
|
||||||
|
<div class="p-4 flex items-center gap-4">
|
||||||
|
<!-- Cover Image -->
|
||||||
|
<div class="w-16 h-20 bg-gray-900 rounded overflow-hidden flex-shrink-0 relative">
|
||||||
|
{% if game.metadata_obj and (game.metadata_obj.cover_image_path or (game.metadata_obj.cover_image and game.metadata_obj.cover_image.startswith('http'))) %}
|
||||||
|
<img data-game-id="{{ game.id }}"
|
||||||
|
{% if game.metadata_obj.cover_image_path %}src="/images/{{ game.metadata_obj.cover_image_path.name }}"{% else %}src="{{ game.metadata_obj.cover_image }}"{% endif %}
|
||||||
|
alt="{{ game.metadata_obj.title or game.title }}"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
|
<div class="w-full h-full bg-gradient-theme hidden items-center justify-center relative overflow-hidden">
|
||||||
|
<!-- Background Pattern -->
|
||||||
|
<div class="absolute inset-0 opacity-10">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="pixel-grid-list-fallback" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||||
|
<rect width="2" height="2" fill="currentColor" opacity="0.3"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#pixel-grid-list-fallback)"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="text-center p-3 relative z-10">
|
||||||
|
<!-- DosVault Logo -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<svg class="w-20 h-20 mx-auto text-accent" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Vault door with enhanced styling -->
|
||||||
|
<circle cx="16" cy="16" r="15" fill="currentColor" opacity="0.15" stroke="currentColor" stroke-width="0.5"/>
|
||||||
|
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.8"/>
|
||||||
|
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.6"/>
|
||||||
|
<circle cx="16" cy="16" r="4" fill="currentColor" opacity="0.4"/>
|
||||||
|
|
||||||
|
<!-- Enhanced DOS-style pixels -->
|
||||||
|
<rect x="5" y="5" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||||
|
<rect x="10" y="5" width="3" height="3" fill="currentColor" opacity="0.9"/>
|
||||||
|
<rect x="19" y="5" width="3" height="3" fill="currentColor" opacity="0.9"/>
|
||||||
|
<rect x="24" y="5" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||||
|
<rect x="5" y="24" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||||
|
<rect x="24" y="24" width="3" height="3" fill="currentColor" opacity="0.7"/>
|
||||||
|
<rect x="5" y="14" width="2" height="2" fill="currentColor" opacity="0.5"/>
|
||||||
|
<rect x="25" y="14" width="2" height="2" fill="currentColor" opacity="0.5"/>
|
||||||
|
|
||||||
|
<!-- Enhanced Handle -->
|
||||||
|
<rect x="19" y="13" width="8" height="6" rx="3" fill="currentColor" opacity="0.8"/>
|
||||||
|
<circle cx="23" cy="16" r="1.5" fill="var(--primary-bg)"/>
|
||||||
|
<circle cx="23" cy="16" r="0.8" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Game Title -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<h3 class="text-accent font-bold text-sm leading-tight mb-1">
|
||||||
|
{{ (game.metadata_obj.title or game.title)[:18] }}{% if (game.metadata_obj.title or game.title)|length > 18 %}...{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div class="w-12 h-0.5 bg-accent mx-auto opacity-60"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Branding -->
|
||||||
|
<div class="text-xs text-secondary opacity-75 font-medium">
|
||||||
|
<div class="mb-1">CLASSIC DOS GAME</div>
|
||||||
|
<div class="text-accent text-[10px] font-bold tracking-wider">DOSVAULT</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decorative Corner Elements -->
|
||||||
|
<div class="absolute top-1 left-1">
|
||||||
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||||
|
<polygon points="0,0 8,0 0,8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-1 right-1">
|
||||||
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||||
|
<polygon points="8,0 8,8 0,0"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-1 left-1">
|
||||||
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||||
|
<polygon points="0,8 0,0 8,8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-1 right-1">
|
||||||
|
<svg class="w-3 h-3 text-accent opacity-40" fill="currentColor" viewBox="0 0 8 8">
|
||||||
|
<polygon points="8,8 0,8 8,0"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="w-full h-full bg-gradient-theme flex items-center justify-center relative overflow-hidden">
|
||||||
|
<!-- Compact Background Pattern -->
|
||||||
|
<div class="absolute inset-0 opacity-8">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 32 32" fill="none">
|
||||||
|
<defs>
|
||||||
|
<pattern id="pixel-grid-small" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
|
||||||
|
<rect width="1" height="1" fill="currentColor" opacity="0.2"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#pixel-grid-small)"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compact Logo -->
|
||||||
|
<div class="text-center relative z-10">
|
||||||
|
<svg class="w-8 h-8 mx-auto text-accent mb-1" viewBox="0 0 32 32" fill="none">
|
||||||
|
<!-- Simplified vault door -->
|
||||||
|
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="1.5" fill="currentColor" opacity="0.1"/>
|
||||||
|
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="1" opacity="0.6"/>
|
||||||
|
<circle cx="16" cy="16" r="4" fill="currentColor" opacity="0.3"/>
|
||||||
|
|
||||||
|
<!-- Minimal pixels -->
|
||||||
|
<rect x="6" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||||
|
<rect x="24" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||||
|
<rect x="6" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||||
|
<rect x="24" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||||
|
|
||||||
|
<!-- Compact handle -->
|
||||||
|
<rect x="20" y="14" width="6" height="4" rx="2" fill="currentColor" opacity="0.7"/>
|
||||||
|
<circle cx="22" cy="16" r="0.8" fill="var(--primary-bg)"/>
|
||||||
|
</svg>
|
||||||
|
<div class="text-accent text-[8px] font-bold tracking-wide opacity-75">DOSVAULT</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Game Info -->
|
||||||
|
<div class="flex-grow min-w-0">
|
||||||
|
<h3 class="text-lg font-semibold text-blue-400 truncate">
|
||||||
|
{{ game.metadata_obj.title or game.title }}
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap gap-4 text-sm text-gray-300 mt-1">
|
||||||
|
{% if game.metadata_obj and game.metadata_obj.year %}
|
||||||
|
<span class="text-gray-400">{{ game.metadata_obj.year }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if game.metadata_obj and game.metadata_obj.developer %}
|
||||||
|
<span class="text-gray-400">{{ game.metadata_obj.developer }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if game.metadata_obj and game.metadata_obj.description %}
|
||||||
|
<p class="text-sm text-gray-400 mt-2 line-clamp-2">
|
||||||
|
{{ game.metadata_obj.description[:120] }}{% if game.metadata_obj.description|length > 120 %}...{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{% if not is_demo %}
|
||||||
|
<button onclick="event.stopPropagation(); toggleFavorite({{ game.id }})"
|
||||||
|
class="w-8 h-8 flex items-center justify-center {% if game.id in user_favorites %}text-red-600{% else %}text-red-400{% endif %} hover:text-red-300 text-xl z-20 relative"
|
||||||
|
id="favorite-{{ game.id }}">
|
||||||
|
{% if game.id in user_favorites %}♥{% else %}♡{% endif %}
|
||||||
|
</button>
|
||||||
|
<button onclick="event.stopPropagation(); downloadGame({{ game.id }})"
|
||||||
|
class="px-3 py-2 bg-green-600 hover:bg-green-700 rounded text-sm z-20 relative">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-3 py-2 bg-gray-600 rounded text-sm cursor-not-allowed">
|
||||||
|
Login to Download
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div class="mt-8 flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||||
|
<div class="text-gray-400 text-sm">
|
||||||
|
Page {{ current_page }} of {{ total_pages }}
|
||||||
|
</div>
|
||||||
|
<nav class="flex items-center space-x-1">
|
||||||
|
{% if current_page > 1 %}
|
||||||
|
<a href="javascript:void(0)" onclick="goToPage({{ current_page - 1 }})"
|
||||||
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- First page -->
|
||||||
|
{% if current_page > 6 %}
|
||||||
|
<a href="javascript:void(0)" onclick="goToPage(1)"
|
||||||
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||||
|
1
|
||||||
|
</a>
|
||||||
|
{% if current_page > 7 %}
|
||||||
|
<span class="px-3 py-2 text-gray-400">...</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Page numbers around current page -->
|
||||||
|
{% for page_num in range(max(1, current_page - 4), min(total_pages + 1, current_page + 5)) %}
|
||||||
|
{% if page_num == current_page %}
|
||||||
|
<span class="px-3 py-2 bg-blue-600 rounded text-sm">{{ page_num }}</span>
|
||||||
|
{% else %}
|
||||||
|
<a href="javascript:void(0)" onclick="goToPage({{ page_num }})"
|
||||||
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||||
|
{{ page_num }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Last page -->
|
||||||
|
{% if current_page < total_pages - 5 %}
|
||||||
|
{% if current_page < total_pages - 6 %}
|
||||||
|
<span class="px-3 py-2 text-gray-400">...</span>
|
||||||
|
{% endif %}
|
||||||
|
<a href="javascript:void(0)" onclick="goToPage({{ total_pages }})"
|
||||||
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||||
|
{{ total_pages }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_page < total_pages %}
|
||||||
|
<a href="javascript:void(0)" onclick="goToPage({{ current_page + 1 }})"
|
||||||
|
class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function buildUrl(params) {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
Object.keys(params).forEach(key => {
|
||||||
|
if (params[key] !== null && params[key] !== '') {
|
||||||
|
url.searchParams.set(key, params[key]);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeView(newView) {
|
||||||
|
window.location.href = buildUrl({ view: newView, page: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePerPage(newPerPage) {
|
||||||
|
window.location.href = buildUrl({ per_page: newPerPage, page: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(page) {
|
||||||
|
window.location.href = buildUrl({ page: page });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFavorite(gameId) {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (!token) {
|
||||||
|
showLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/games/${gameId}/favorite`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const favoriteBtn = document.getElementById(`favorite-${gameId}`);
|
||||||
|
const isFavorited = favoriteBtn.textContent.trim() === '♥';
|
||||||
|
|
||||||
|
if (isFavorited) {
|
||||||
|
// Remove from favorites
|
||||||
|
favoriteBtn.textContent = '♡';
|
||||||
|
favoriteBtn.classList.remove('text-red-600');
|
||||||
|
favoriteBtn.classList.add('text-red-400');
|
||||||
|
} else {
|
||||||
|
// Add to favorites
|
||||||
|
favoriteBtn.textContent = '♥';
|
||||||
|
favoriteBtn.classList.remove('text-red-400');
|
||||||
|
favoriteBtn.classList.add('text-red-600');
|
||||||
|
}
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling favorite:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadGame(gameId) {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (!token) {
|
||||||
|
showLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/download/${gameId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.style.display = 'none';
|
||||||
|
a.href = url;
|
||||||
|
a.download = response.headers.get('Content-Disposition')?.split('filename=')[1] || 'game.zip';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
showLogin();
|
||||||
|
} else {
|
||||||
|
alert('Download failed. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download error:', error);
|
||||||
|
alert('Download failed. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track which covers are being loaded to prevent duplicates
|
||||||
|
const loadingCovers = new Set();
|
||||||
|
|
||||||
|
// Lazy load cover images that failed to load initially
|
||||||
|
async function loadCoverImage(gameId, imgElement) {
|
||||||
|
const loadKey = `cover-${gameId}`;
|
||||||
|
|
||||||
|
// Prevent multiple concurrent requests for the same cover
|
||||||
|
if (loadingCovers.has(loadKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingCovers.add(loadKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/cover/${gameId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.cover_url) {
|
||||||
|
imgElement.src = data.cover_url;
|
||||||
|
imgElement.style.display = 'block';
|
||||||
|
const placeholder = imgElement.nextElementSibling;
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No cover found, show "No Cover" message
|
||||||
|
const placeholder = imgElement.nextElementSibling;
|
||||||
|
if (placeholder) {
|
||||||
|
const textElement = placeholder.querySelector('p');
|
||||||
|
if (textElement) {
|
||||||
|
textElement.textContent = 'No Cover';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading cover image:', error);
|
||||||
|
// Show error state
|
||||||
|
const placeholder = imgElement.nextElementSibling;
|
||||||
|
if (placeholder) {
|
||||||
|
const textElement = placeholder.querySelector('p');
|
||||||
|
if (textElement) {
|
||||||
|
textElement.textContent = 'No Cover';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingCovers.delete(loadKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy loading disabled - you can investigate the CORS issue
|
||||||
|
// document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// // Lazy loading code commented out
|
||||||
|
// });
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
75
test_images.py
Normal file
75
test_images.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Test script to download images for existing games that don't have local images yet.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
from pathlib import Path
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.libs.config import Config
|
||||||
|
from src.libs.database import Game_table, Metadata_table
|
||||||
|
from src.libs.functions import download_image, get_image_filename
|
||||||
|
|
||||||
|
async def test_image_downloads():
|
||||||
|
config = Config()
|
||||||
|
url = f"sqlite+pysqlite:///{config.database_path}"
|
||||||
|
engine = create_engine(url, future=True)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
# Get first 3 games that have remote images but no local images
|
||||||
|
stmt = (
|
||||||
|
select(Game_table)
|
||||||
|
.join(Metadata_table)
|
||||||
|
.where(
|
||||||
|
(Metadata_table.cover_image.is_not(None)) &
|
||||||
|
(Metadata_table.cover_image_path.is_(None))
|
||||||
|
)
|
||||||
|
.limit(3)
|
||||||
|
)
|
||||||
|
games = session.scalars(stmt).all()
|
||||||
|
|
||||||
|
print(f"Found {len(games)} games to test image downloads for")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as http_session:
|
||||||
|
for game in games:
|
||||||
|
metadata = game.metadata_obj
|
||||||
|
print(f"\nTesting: {game.title}")
|
||||||
|
|
||||||
|
# Download cover image
|
||||||
|
if metadata.cover_image:
|
||||||
|
cover_filename = get_image_filename(metadata.cover_image, game.title, 'cover')
|
||||||
|
cover_path = config.images_path / cover_filename
|
||||||
|
|
||||||
|
print(f" Downloading cover: {metadata.cover_image}")
|
||||||
|
success = await download_image(metadata.cover_image, cover_path, http_session)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f" ✓ Cover saved to: {cover_path}")
|
||||||
|
# Update database with local path
|
||||||
|
metadata.cover_image_path = cover_path
|
||||||
|
else:
|
||||||
|
print(f" ✗ Failed to download cover")
|
||||||
|
|
||||||
|
# Download screenshot
|
||||||
|
if metadata.screenshot:
|
||||||
|
screenshot_filename = get_image_filename(metadata.screenshot, game.title, 'screenshot')
|
||||||
|
screenshot_path = config.images_path / screenshot_filename
|
||||||
|
|
||||||
|
print(f" Downloading screenshot: {metadata.screenshot}")
|
||||||
|
success = await download_image(metadata.screenshot, screenshot_path, http_session)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f" ✓ Screenshot saved to: {screenshot_path}")
|
||||||
|
# Update database with local path
|
||||||
|
metadata.screenshot_path = screenshot_path
|
||||||
|
else:
|
||||||
|
print(f" ✗ Failed to download screenshot")
|
||||||
|
|
||||||
|
# Commit the updates
|
||||||
|
session.commit()
|
||||||
|
print(f"\n✓ Database updated with local image paths")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_image_downloads())
|
||||||
Reference in New Issue
Block a user