Iniital release of DosVault.

This commit is contained in:
2025-09-06 13:53:44 -04:00
commit b3e71456c8
41 changed files with 7391 additions and 0 deletions

62
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"}

View 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')

View 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')

View 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')

View File

@@ -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
View File

@@ -0,0 +1,3 @@
[pytest]
addopts = --cov=src --cov-report=term-missing --ignore=src/__main__.py
testpaths = tests/

21
requirements.txt Normal file
View 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
View File

159
src/__main__.py Executable file
View 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
View 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
View 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
View File

101
src/libs/apis.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

1390
templates/admin.html Normal file

File diff suppressed because it is too large Load Diff

180
templates/admin_users.html Normal file
View 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">&times;</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
View 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">&times;</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">&times;</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
View 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
View 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
View 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">
&times;
</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
View 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
View 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())