4 Commits

Author SHA1 Message Date
3951794ba9 Working on socket communications 2025-09-21 10:20:18 -04:00
daaa0b5fee Ready to merge 2025-09-08 20:02:20 -04:00
5d837c5501 Restore missing project configuration files
Restored all configuration and documentation files that were missing:
- devenv.nix and devenv.lock for development environment
- README.md, CLAUDE.md, DOCKER.md, WARP.md for documentation
- alembic.ini for database migrations
- requirements.txt for Python dependencies
- Dockerfile, docker-compose.yml, entrypoint.sh for containerization
- build.sh for build automation
- pytest.ini for test configuration

All database concurrency improvements and logging fixes remain intact.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 13:05:08 -04:00
7e4c194c1f Added new rom import system utilizing WAL to avoid locking the database and freezing the frontend
Also added new logging setup to hopefully stream the scrape process
2025-09-07 12:50:05 -04:00
10 changed files with 385 additions and 189 deletions

View File

@@ -42,6 +42,7 @@
passlib passlib
alembic alembic
aiohttp aiohttp
websockets
''; '';
}; };
# uv = { # uv = {

View File

@@ -44,7 +44,7 @@ async def scrape_metadata(title: str, session: aiohttp.ClientSession) -> Metadat
cover_data = game_data.get('cover') cover_data = game_data.get('cover')
if cover_data and cover_data.get('image_id'): if cover_data and cover_data.get('image_id'):
md.cover_image = IGDB.build_cover_url(cover_data['image_id'], 'cover_big') md.cover_image = IGDB.build_cover_url(cover_data['image_id'], 'cover_big')
# Download cover image locally # Download cover image locally
cover_filename = get_image_filename(md.cover_image, title, 'cover') cover_filename = get_image_filename(md.cover_image, title, 'cover')
cover_path = config.images_path / cover_filename cover_path = config.images_path / cover_filename
@@ -60,7 +60,7 @@ async def scrape_metadata(title: str, session: aiohttp.ClientSession) -> Metadat
artworks = game_data.get('artworks', []) artworks = game_data.get('artworks', [])
if artworks and artworks[0].get('image_id'): if artworks and artworks[0].get('image_id'):
md.screenshot = IGDB.build_cover_url(artworks[0]['image_id'], 'screenshot_med') md.screenshot = IGDB.build_cover_url(artworks[0]['image_id'], 'screenshot_med')
# Download screenshot locally # Download screenshot locally
screenshot_filename = get_image_filename(md.screenshot, title, 'screenshot') screenshot_filename = get_image_filename(md.screenshot, title, 'screenshot')
screenshot_path = config.images_path / screenshot_filename screenshot_path = config.images_path / screenshot_filename
@@ -99,15 +99,19 @@ async def inject_metadata(roms: Roms) -> Roms:
try: try:
await asyncio.sleep(0.25) # keep your throttle await asyncio.sleep(0.25) # keep your throttle
md = await scrape_metadata(game.title, session) md = await scrape_metadata(game.title, session)
except ValueError: results[i] = md
scrape_errors.append(game.title) logging.info(f"Successfully scraped: {game.title} # {i+1}/{len(roms.list)}")
except Exception as e:
# Handle all exceptions, not just ValueError
scrape_errors.append(f"{game.title}: {str(e)}")
md = Metadata(title=game.title, year=extract_year_from_title(game.title)) md = Metadata(title=game.title, year=extract_year_from_title(game.title))
# log each item as its done results[i] = md
results[i] = md logging.info(f"Used fallback metadata for: {game.title} # {i+1}/{len(roms.list)}")
logging.info(f"Scraped: {game.title} # {i+1}/{len(roms.list)}")
# Log recent errors # Log error details every 5 errors to avoid spam but provide visibility
for err in scrape_errors[-5:]: if len(scrape_errors) % 5 == 0:
logging.warning(f"Scraping error: {err}") logging.warning(f"Scraping error for {game.title}: {str(e)}")
logging.info(f"Total scraping errors so far: {len(scrape_errors)}")
tasks = [asyncio.create_task(_job(i, game)) for i, game in enumerate(roms.list)] tasks = [asyncio.create_task(_job(i, game)) for i, game in enumerate(roms.list)]
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
@@ -120,40 +124,64 @@ async def inject_metadata(roms: Roms) -> Roms:
async def filter_new_roms(romlist: Roms, session: Session) -> Roms: async def filter_new_roms(romlist: Roms, session: Session) -> Roms:
existing_paths = get_existing_rom_paths(session) existing_paths = get_existing_rom_paths(session)
new_roms = Roms() new_roms = Roms()
for game in romlist.list: for game in romlist.list:
if game.path.resolve() not in existing_paths: if game.path.resolve() not in existing_paths:
new_roms.list.append(game) new_roms.list.append(game)
logging.info(f"Found {len(romlist.list)} total ROMs") logging.info(f"Found {len(romlist.list)} total ROMs")
logging.info(f"Found {len(existing_paths)} existing ROMs in database") logging.info(f"Found {len(existing_paths)} existing ROMs in database")
logging.info(f"Will scrape {len(new_roms.list)} new ROMs") logging.info(f"Will scrape {len(new_roms.list)} new ROMs")
return new_roms return new_roms
async def main(): async def main():
url = f"sqlite+pysqlite:///{config.database_path}" url = f"sqlite+pysqlite:///{config.database_path}"
engine = create_engine(url, future=True) engine = create_engine(
# Database tables are now managed by migrations url,
# Base.metadata.create_all(engine) future=True,
connect_args={
with Session(engine) as s: "timeout": 10, # 10 second timeout instead of default 30
romlist = await make_romlist() "check_same_thread": False
new_romlist = await filter_new_roms(romlist, s) },
pool_pre_ping=True
if new_romlist.list: )
new_romlist = await inject_metadata(new_romlist)
ingest_roms(new_romlist, s)
else:
logging.info("No new ROMs to scrape!")
logging.info("ROM scanning completed") try:
if scrape_errors: with Session(engine) as s:
logging.warning(f"Total scraping errors: {len(scrape_errors)}") # Enable WAL mode for better concurrency
for err in scrape_errors: try:
logging.warning(f"Failed to scrape: {err}") s.execute("PRAGMA journal_mode=WAL")
else: s.execute("PRAGMA busy_timeout=5000") # 5 second busy timeout
logging.info("ROM scanning completed with no errors") s.commit()
logging.info("Enabled WAL mode for better database concurrency")
except Exception as e:
logging.warning(f"Could not enable WAL mode: {e}")
romlist = await make_romlist()
new_romlist = await filter_new_roms(romlist, s)
if new_romlist.list:
logging.info(f"Starting metadata scraping for {len(new_romlist.list)} new games")
new_romlist = await inject_metadata(new_romlist)
logging.info("Starting database ingestion with smaller batches")
# Use smaller batches to reduce database lock time
ingest_roms(new_romlist, s, batch=50)
else:
logging.info("No new ROMs to scrape!")
logging.info("ROM scanning completed successfully")
if scrape_errors:
logging.warning(f"Total scraping errors: {len(scrape_errors)}")
for err in scrape_errors[-10:]: # Show last 10 errors only
logging.warning(f"Failed to scrape: {err}")
else:
logging.info("ROM scanning completed with no metadata errors")
except Exception as e:
logging.error(f"ROM scanning failed with error: {e}")
raise
if __name__ == "__main__": if __name__ == "__main__":
# Initialize logging # Initialize logging

View File

@@ -7,8 +7,9 @@ from jose import JWTError, jwt
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import select from sqlalchemy import select
from .database import User_table, UserRole from .database import User_table, UserRole
from .config import Config
SECRET_KEY = "your-secret-key-change-this-in-production" SECRET_KEY = Config().secret_key
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30 ACCESS_TOKEN_EXPIRE_MINUTES = 30
@@ -71,4 +72,4 @@ class AuthManager:
session.add(user) session.add(user)
session.commit() session.commit()
session.refresh(user) session.refresh(user)
return user return user

View File

@@ -27,6 +27,7 @@ class Config:
websocket_port: int = 8081 websocket_port: int = 8081
igdb_api_key: str = "" igdb_api_key: str = ""
igdb_client_id: str = "" igdb_client_id: str = ""
secret_key: str = ""
def __init__(self, path: Optional[Path] = None): def __init__(self, path: Optional[Path] = None):
if path: if path:
@@ -65,6 +66,8 @@ class Config:
return { return {
"rom_path": str(self.rom_path), "rom_path": str(self.rom_path),
"metadata_path": str(self.metadata_path), "metadata_path": str(self.metadata_path),
"database_path": str(self.database_path),
"images_path": str(self.images_path),
"host": self.host, "host": self.host,
"port": self.port, "port": self.port,
"websocket_port": self.websocket_port, "websocket_port": self.websocket_port,
@@ -72,22 +75,28 @@ class Config:
"igdb_client_id": self.igdb_client_id, "igdb_client_id": self.igdb_client_id,
} }
def make_secret_key(self, length: int = 32) -> str:
import secrets
import string
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
def save(self): def save(self):
# Ensure config directory exists
if not self.path.parent.exists(): if not self.path.parent.exists():
self.path.parent.mkdir(parents=True, exist_ok=True) 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() # Create directories if they don't exist
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(): if not self.rom_path.exists():
self.rom_path.mkdir(parents=True, exist_ok=True) self.rom_path.mkdir(parents=True, exist_ok=True)
if not self.metadata_path.exists(): if not self.metadata_path.exists():
self.metadata_path.mkdir(parents=True, exist_ok=True) self.metadata_path.mkdir(parents=True, exist_ok=True)
if not self.images_path.exists(): if not self.images_path.exists():
self.images_path.mkdir(parents=True, exist_ok=True) self.images_path.mkdir(parents=True, exist_ok=True)
# Write configuration to file
with open(self.path, 'w') as f: with open(self.path, 'w') as f:
json.dump(self.to_dict(), f, indent=4) json.dump(self.to_dict(), f, indent=4)
f.close()
def load(self) -> "Config": def load(self) -> "Config":
if self.path.exists(): if self.path.exists():
@@ -95,19 +104,28 @@ class Config:
data = json.load(f) data = json.load(f)
self.rom_path = Path(data.get("rom_path", str(self.rom_path))) 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.metadata_path = Path(data.get("metadata_path", str(self.metadata_path)))
self.database_path = Path(data.get("database_path", str(self.database_path)))
self.images_path = Path(data.get("images_path", str(self.images_path)))
self.host = data.get("host", self.host) self.host = data.get("host", self.host)
self.port = data.get("port", self.port) self.port = data.get("port", self.port)
self.websocket_port = data.get("websocket_port", self.websocket_port) self.websocket_port = data.get("websocket_port", self.websocket_port)
self.igdb_api_key = data.get("igdb_api_key", self.igdb_api_key)
self.igdb_client_id = data.get("igdb_client_id", self.igdb_client_id)
self.secret_key = data.get("secret_key", self.secret_key)
# Load environment secrets if API keys are still empty
if self.igdb_api_key == "" or self.igdb_client_id == "": if self.igdb_api_key == "" or self.igdb_client_id == "":
secrets = self.load_env_secrets() secrets = self.load_env_secrets()
if secrets: if secrets:
self.igdb_api_key = secrets.get("IGDB_SECRET_KEY", "") self.igdb_api_key = secrets.get("IGDB_SECRET_KEY", self.igdb_api_key)
self.igdb_client_id = secrets.get("IGDB_CLIENT_ID", "") self.igdb_client_id = secrets.get("IGDB_CLIENT_ID", self.igdb_client_id)
f.close()
self.save()
return self
f.close()
else: else:
# Config file doesn't exist, create it with defaults
# Load environment secrets for initial setup
secrets = self.load_env_secrets()
if secrets:
self.igdb_api_key = secrets.get("IGDB_SECRET_KEY", self.igdb_api_key)
self.igdb_client_id = secrets.get("IGDB_CLIENT_ID", self.igdb_client_id)
self.secret_key = self.make_secret_key(32)
self.save() self.save()
self.load()
return self return self

View File

@@ -199,43 +199,62 @@ def get_existing_rom_paths(session: Session) -> set[Path]:
return {game.path.resolve() for game in session.scalars(select(Game_table)).all()} return {game.path.resolve() for game in session.scalars(select(Game_table)).all()}
def ingest_roms(roms: Roms, session: Session, *, batch: int = 200) -> int: def ingest_roms(roms: Roms, session: Session, *, batch: int = 200) -> int:
import logging
n = 0 n = 0
for g in roms.list: for g in roms.list:
game = session.scalar(select(Game_table).where(Game_table.path == g.path)) try:
if game is None: game = session.scalar(select(Game_table).where(Game_table.path == g.path))
game = Game_table(title=g.title, path=g.path) if game is None:
session.add(game) game = Game_table(title=g.title, path=g.path)
else: session.add(game)
game.title = g.title logging.info(f"Adding new game: {g.title}")
mdto = g.metadata else:
md = game.metadata_obj game.title = g.title
if md is None: logging.info(f"Updating existing game: {g.title}")
md = Metadata_table(game=game, title=mdto.title or g.title)
session.add(md) 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.title = mdto.title or g.title
md.description = mdto.description md.description = mdto.description
md.year = mdto.year if mdto.year is not None else extract_year_from_title(md.title) md.year = mdto.year if mdto.year is not None else extract_year_from_title(md.title)
md.developer = mdto.developer md.developer = mdto.developer
md.publisher = mdto.publisher md.publisher = mdto.publisher
md.players = mdto.players md.players = mdto.players
md.cover_image = mdto.cover_image md.cover_image = mdto.cover_image
md.screenshot = mdto.screenshot md.screenshot = mdto.screenshot
md.cover_image_path = mdto.cover_image_path md.cover_image_path = mdto.cover_image_path
md.screenshot_path = mdto.screenshot_path md.screenshot_path = mdto.screenshot_path
try: genres = sorted({s.strip() for s in (mdto.genre or []) if s and s.strip()}) try: genres = sorted({s.strip() for s in (mdto.genre or []) if s and s.strip()})
except: genres = [] except: genres = []
try: tags = sorted({s.strip() for s in (mdto.tags or []) if s and s.strip()}) try: tags = sorted({s.strip() for s in (mdto.tags or []) if s and s.strip()})
except: tags = [] except: tags = []
md.genre = [_get_or_create_by_name(session, Genre_table, name) for name in genres] 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] md.tags = [_get_or_create_by_name(session, Tags_table, name) for name in tags]
n += 1 n += 1
if n % batch == 0:
session.flush() # Use more frequent flushes and commits to reduce lock time
if n % batch == 0:
session.commit() # Commit more frequently to reduce lock duration
logging.info(f"Committed batch of {batch} games to database ({n} total)")
except Exception as e:
logging.error(f"Failed to ingest game {g.title}: {e}")
session.rollback() # Rollback on error to prevent corruption
continue
session.commit() # Final commit for remaining items
try:
session.commit()
logging.info(f"Successfully ingested {n} games to database")
except Exception as e:
logging.error(f"Failed final commit during ROM ingestion: {e}")
session.rollback()
return n return n

View File

@@ -1,13 +1,16 @@
#!/usr/bin/env python #!/usr/bin/env python
from __future__ import annotations from __future__ import annotations
from typing import Optional, Annotated from typing import Optional, Annotated, Dict
from datetime import timedelta, datetime, timezone from datetime import timedelta, datetime, timezone
import re import re
import asyncio import asyncio
import logging
import subprocess
import json
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Depends, HTTPException, status, Request, Form, Query, BackgroundTasks from fastapi import FastAPI, Depends, HTTPException, status, Request, Form, Query, BackgroundTasks, WebSocket
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -15,18 +18,18 @@ from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.exception_handlers import http_exception_handler from fastapi.exception_handlers import http_exception_handler
from fastapi.exceptions import HTTPException as StarletteHTTPException from fastapi.exceptions import HTTPException as StarletteHTTPException
from starlette.exceptions import HTTPException as StarletteBaseHTTPException
from sqlalchemy import create_engine, select, func from sqlalchemy import create_engine, select, func
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from alembic.config import Config as AlembicConfig from alembic.config import Config as AlembicConfig
from alembic import command from alembic import command
import subprocess from websockets.asyncio.client import connect
from pydantic import BaseModel
try: try:
# Try relative imports first (when run as module) # Try relative imports first (when run as module)
from .libs.config import Config from .libs.config import Config
from .libs.database import Base, Game_table, Metadata_table, User_table, UserRole, user_favorites, Tags_table, Genre_table from .libs.database import Game_table, Metadata_table, User_table, UserRole, user_favorites, Tags_table, Genre_table
from .libs.auth import AuthManager, ACCESS_TOKEN_EXPIRE_MINUTES from .libs.auth import AuthManager, ACCESS_TOKEN_EXPIRE_MINUTES
from .libs.logging import get_log_manager from .libs.logging import get_log_manager
except ImportError: except ImportError:
@@ -36,13 +39,35 @@ except ImportError:
from libs.auth import AuthManager, ACCESS_TOKEN_EXPIRE_MINUTES from libs.auth import AuthManager, ACCESS_TOKEN_EXPIRE_MINUTES
from libs.logging import get_log_manager from libs.logging import get_log_manager
# Initialize logging system first
get_log_manager()
config = Config() config = Config()
engine = create_engine(f"sqlite+pysqlite:///{config.database_path}", echo=False) engine = create_engine(
f"sqlite+pysqlite:///{config.database_path}",
echo=False,
connect_args={
"timeout": 10, # 10 second timeout
"check_same_thread": False
},
pool_pre_ping=True
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Initialize logging system class GameData(BaseModel):
import logging filename: str
get_log_manager() url: str
# Enable WAL mode for better concurrency during startup
try:
with engine.connect() as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000") # 5 second busy timeout
conn.commit()
logging.info("Enabled WAL mode for web application database")
except Exception as e:
logging.warning(f"Could not enable WAL mode for web application: {e}")
logging.info("DosVault web application starting up") logging.info("DosVault web application starting up")
app = FastAPI(title="DOS Frontend", description="ROM Management System") app = FastAPI(title="DOS Frontend", description="ROM Management System")
@@ -119,7 +144,7 @@ def ensure_super_user():
# Create default super user # Create default super user
logging.info("No super user found, creating default admin user...") logging.info("No super user found, creating default admin user...")
try: try:
default_admin = AuthManager.create_user( AuthManager.create_user(
session=db, session=db,
username="admin", username="admin",
email="admin@dosvault.local", email="admin@dosvault.local",
@@ -609,6 +634,39 @@ async def toggle_favorite(
return {"message": f"Game {action} from favorites"} return {"message": f"Game {action} from favorites"}
@app.post("/run")
async def run_game(
request: Request,
game_data: GameData,
current_user: User_table = Depends(require_auth)
):
breakpoint()
logging.info(f"Run request for game ID {game_data.filename} from user {current_user.username}")
game_json = json.dumps(game_data.dict())
if current_user.role == UserRole.DEMO.value:
raise HTTPException(status_code=403, detail="Demo users cannot download games")
web_socket_port: int = config.websocket_port
try:
ws = connect(f"ws://{ip_address}:{web_socket_port}")
except Exception as e:
logging.error(f"WebSocket connection error: {e}")
raise HTTPException(status_code=400, detail="Could not connect to client WebSocket")
try:
async with ws as websocket:
await websocket.send(f"RUN::{game_json}")
response = await websocket.recv()
if response != "OK":
logging.error(f"Client returned error: {response}")
raise HTTPException(status_code=400, detail=f"Client error: {response}")
else:
logging.info(f"Sent RUN command for game ")
except Exception as e:
logging.error(f"WebSocket communication error: {e}")
raise HTTPException(status_code=400, detail="Error communicating with client WebSocket")
return {"web_socket_port": web_socket_port, "game_path": str(game_data.filename)}
@app.get("/download/{game_id}") @app.get("/download/{game_id}")
async def download_game( async def download_game(
game_id: int, game_id: int,
@@ -807,7 +865,7 @@ async def create_user(
if existing_user: if existing_user:
raise HTTPException(status_code=400, detail="Username or email already exists") raise HTTPException(status_code=400, detail="Username or email already exists")
new_user = AuthManager.create_user(db, username, email, password, role) AuthManager.create_user(db, username, email, password, role)
return RedirectResponse(url="/admin/users", status_code=303) return RedirectResponse(url="/admin/users", status_code=303)
@@ -1017,6 +1075,21 @@ async def admin_rom_scan(
return {"status": "started", "message": "ROM scan started"} return {"status": "started", "message": "ROM scan started"}
@app.post("/api/admin/game-scan")
async def admin_game_scan(
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User_table = Depends(require_super_user)
):
"""Trigger game scan in the background (alias for ROM scan)"""
if "game_scan" in running_tasks and not running_tasks["game_scan"].done():
return {"status": "already_running", "message": "Game scan is already in progress"}
task = asyncio.create_task(run_rom_scan())
running_tasks["game_scan"] = task
return {"status": "started", "message": "Game scan started"}
@app.post("/api/admin/metadata-refresh") @app.post("/api/admin/metadata-refresh")
async def admin_metadata_refresh( async def admin_metadata_refresh(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
@@ -1134,7 +1207,7 @@ async def admin_system_stats(
"disk_usage": disk_usage, "disk_usage": disk_usage,
"running_tasks": { "running_tasks": {
task_name: not running_tasks[task_name].done() if task_name in running_tasks else False task_name: not running_tasks[task_name].done() if task_name in running_tasks else False
for task_name in ["rom_scan", "metadata_refresh", "image_sync"] for task_name in ["rom_scan", "game_scan", "metadata_refresh", "image_sync"]
} }
} }
@@ -1142,31 +1215,43 @@ async def run_rom_scan():
"""Run the ROM scanner subprocess""" """Run the ROM scanner subprocess"""
try: try:
logging.info("Starting ROM scan subprocess") logging.info("Starting ROM scan subprocess")
# Use the same approach as devenv - run the script directly with proper PYTHONPATH
script_path = Path(__file__).parent / "__main__.py" # Point to src/__main__.py
# Create subprocess with real-time output capture
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
"python", "-m", "src", "python", str(script_path),
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.STDOUT, # Merge stderr into stdout for unified logging
cwd=Path(__file__).parent.parent # Set working directory to project root
) )
stdout, stderr = await process.communicate()
# Log the output for visibility # Capture output in real-time and log it
if stdout: output_lines = []
for line in stdout.decode().strip().split('\n'): try:
if line.strip(): while True:
logging.info(f"ROM Scanner: {line.strip()}") line = await process.stdout.readline()
if not line:
break
decoded_line = line.decode().rstrip()
if decoded_line:
output_lines.append(decoded_line)
# Log to main application log immediately for real-time visibility
logging.info(f"ROM Scanner: {decoded_line}")
except Exception as e:
logging.error(f"Error reading ROM scanner output: {e}")
if stderr: # Wait for process to complete
for line in stderr.decode().strip().split('\n'): await process.wait()
if line.strip():
logging.error(f"ROM Scanner Error: {line.strip()}")
success = process.returncode == 0 success = process.returncode == 0
logging.info(f"ROM scan subprocess completed with exit code: {process.returncode}") logging.info(f"ROM scan subprocess completed with exit code: {process.returncode}")
return { return {
"success": success, "success": success,
"output": stdout.decode(), "output": '\n'.join(output_lines),
"error": stderr.decode(), "error": "",
"returncode": process.returncode "returncode": process.returncode
} }
except Exception as e: except Exception as e:
@@ -1594,4 +1679,4 @@ async def catch_all_404(request: Request, path: str):
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host=config.host, port=config.port) uvicorn.run(app, host=config.host, port=config.port)

View File

@@ -1305,22 +1305,22 @@ async function loadConfiguration() {
function populateConfigForm(config) { function populateConfigForm(config) {
// Populate form fields // Populate form fields
document.getElementById('config-host').value = config.host || ''; document.getElementById('config-host').value = config.config?.host || config.host || '';
document.getElementById('config-port').value = config.port || ''; document.getElementById('config-port').value = config.config?.port || config.port || '';
document.getElementById('config-rom-path').value = config.game_path || ''; document.getElementById('config-rom-path').value = config.config?.rom_path || config.rom_path || '';
document.getElementById('config-images-path').value = config.images_path || ''; document.getElementById('config-images-path').value = config.config?.images_path || config.images_path || '';
document.getElementById('config-igdb-client-id').value = config.igdb_client_id || ''; document.getElementById('config-igdb-client-id').value = config.config?.igdb_client_id || config.igdb_client_id || '';
document.getElementById('config-igdb-secret').value = config.igdb_api_key || ''; document.getElementById('config-igdb-secret').value = config.config?.igdb_api_key || config.igdb_api_key || '';
// Populate JSON editor // Populate JSON editor
document.getElementById('config-json').value = JSON.stringify(config, null, 2); document.getElementById('config-json').value = JSON.stringify(config.config || config, null, 2);
} }
function collectConfigFromForm() { function collectConfigFromForm() {
const config = { const config = {
host: document.getElementById('config-host').value, host: document.getElementById('config-host').value,
port: parseInt(document.getElementById('config-port').value) || 8080, port: parseInt(document.getElementById('config-port').value) || 8080,
game_path: document.getElementById('config-rom-path').value, rom_path: document.getElementById('config-rom-path').value,
images_path: document.getElementById('config-images-path').value, images_path: document.getElementById('config-images-path').value,
igdb_client_id: document.getElementById('config-igdb-client-id').value, igdb_client_id: document.getElementById('config-igdb-client-id').value,
igdb_api_key: document.getElementById('config-igdb-secret').value igdb_api_key: document.getElementById('config-igdb-secret').value

View File

@@ -38,6 +38,10 @@
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded"> class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded">
Download Game Download Game
</button> </button>
<button onclick="runGame({{ game.id }})"
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded">
Run Game
</button>
{% else %} {% else %}
<span class="bg-gray-600 px-4 py-2 rounded cursor-not-allowed"> <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 %} {% if current_user %}Demo Mode - No Downloads{% else %}Login to Download{% endif %}
@@ -287,5 +291,34 @@
alert('Download failed. Please try again.'); alert('Download failed. Please try again.');
} }
} }
async function runGame(gameId) {
const token = localStorage.getItem('authToken');
if (!token) {
showLogin();
return;
}
try {
const response = await fetch(`/run/${gameId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
alert('Game is starting in your DOSBox environment.');
} else if (response.status === 401) {
localStorage.removeItem('authToken');
showLogin();
} else {
alert('Failed to start the game. Please try again.');
}
} catch (error) {
console.error('Error starting game:', error);
alert('Failed to start the game. Please try again.');
}
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -480,6 +480,10 @@
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded hidden"> class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded hidden">
Download Game Download Game
</button> </button>
<button onclick="runGameFromOverlay()"
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded">
Run Game
</button>
<span id="gameDownloadDisabled" class="bg-gray-600 px-4 py-2 rounded cursor-not-allowed hidden"> <span id="gameDownloadDisabled" class="bg-gray-600 px-4 py-2 rounded cursor-not-allowed hidden">
Demo Mode - No Downloads Demo Mode - No Downloads
@@ -660,6 +664,83 @@
alert('Download failed. Please try again.'); alert('Download failed. Please try again.');
} }
} }
async function downloadGameStorage(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;
// Get filename from Content-Disposition header, removing quotes and underscores
let filename = response.headers.get('Content-Disposition')?.split('filename=')[1] || 'game.zip';
filename = filename.replace(/^["\_]+|["\_]+$/g, ''); // Remove quotes and underscores from start and end
a.download = filename;
return { url: url, filename: filename };
} 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.');
}
}
async function runGame(gameId) {
const token = localStorage.getItem('authToken');
if (!token) {
showLogin();
return;
}
const file = await downloadGameStorage(gameId);
const response = await fetch(file.url)
const arrayBuffer = await response.arrayBuffer();
const blob = new Blob([arrayBuffer],
{ type: response.headers.get(
'content-type') || 'application/octet-stream'
}
);
const _data = new FormData();
debugger;
_data.append('file', blob, ".zip");
try {
const response = await fetch(`/run`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
//body: JSON.stringify(file)
body: _data
});
if (response.ok) {
alert('Game is starting in your DOSBox environment.');
} else if (response.status === 401) {
localStorage.removeItem('authToken');
showLogin();
} else {
alert('Failed to start the game. Please try again.');
}
} catch (error) {
console.error('Error starting game:', error);
alert('Failed to start the game. Please try again.');
}
}
// Track which covers are being loaded to prevent duplicates // Track which covers are being loaded to prevent duplicates
const loadingCovers = new Set(); const loadingCovers = new Set();
@@ -937,6 +1018,10 @@
if (!currentGameData) return; if (!currentGameData) return;
await downloadGame(currentGameData.id); await downloadGame(currentGameData.id);
} }
async function runGameFromOverlay() {
if (!currentGameData) return;
await runGame(currentGameData.id);
}
function openScreenshotModalInOverlay() { function openScreenshotModalInOverlay() {
if (!currentGameData || !currentGameData.metadata.screenshot) return; if (!currentGameData || !currentGameData.metadata.screenshot) return;
@@ -989,4 +1074,4 @@
} }
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,74 +0,0 @@
#!/usr/bin/env python
"""
Test script to download images for existing games that don't have local images yet.
"""
import asyncio
import aiohttp
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(" ✗ 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(" ✗ Failed to download screenshot")
# Commit the updates
session.commit()
print("\n✓ Database updated with local image paths")
if __name__ == "__main__":
asyncio.run(test_image_downloads())