2 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
7 changed files with 187 additions and 22 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
@@ -107,7 +107,7 @@ async def inject_metadata(roms: Roms) -> Roms:
md = Metadata(title=game.title, year=extract_year_from_title(game.title)) md = Metadata(title=game.title, year=extract_year_from_title(game.title))
results[i] = md results[i] = md
logging.info(f"Used fallback metadata for: {game.title} # {i+1}/{len(roms.list)}") logging.info(f"Used fallback metadata for: {game.title} # {i+1}/{len(roms.list)}")
# Log error details every 5 errors to avoid spam but provide visibility # Log error details every 5 errors to avoid spam but provide visibility
if len(scrape_errors) % 5 == 0: if len(scrape_errors) % 5 == 0:
logging.warning(f"Scraping error for {game.title}: {str(e)}") logging.warning(f"Scraping error for {game.title}: {str(e)}")
@@ -124,20 +124,19 @@ 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}"
# Use a connection with shorter timeout and WAL mode for better concurrency
engine = create_engine( engine = create_engine(
url, url,
future=True, future=True,
@@ -147,10 +146,7 @@ async def main():
}, },
pool_pre_ping=True pool_pre_ping=True
) )
# Database tables are now managed by migrations
# Base.metadata.create_all(engine)
try: try:
with Session(engine) as s: with Session(engine) as s:
# Enable WAL mode for better concurrency # Enable WAL mode for better concurrency
@@ -161,14 +157,14 @@ async def main():
logging.info("Enabled WAL mode for better database concurrency") logging.info("Enabled WAL mode for better database concurrency")
except Exception as e: except Exception as e:
logging.warning(f"Could not enable WAL mode: {e}") logging.warning(f"Could not enable WAL mode: {e}")
romlist = await make_romlist() romlist = await make_romlist()
new_romlist = await filter_new_roms(romlist, s) new_romlist = await filter_new_roms(romlist, s)
if new_romlist.list: if new_romlist.list:
logging.info(f"Starting metadata scraping for {len(new_romlist.list)} new games") logging.info(f"Starting metadata scraping for {len(new_romlist.list)} new games")
new_romlist = await inject_metadata(new_romlist) new_romlist = await inject_metadata(new_romlist)
logging.info("Starting database ingestion with smaller batches") logging.info("Starting database ingestion with smaller batches")
# Use smaller batches to reduce database lock time # Use smaller batches to reduce database lock time
ingest_roms(new_romlist, s, batch=50) ingest_roms(new_romlist, s, batch=50)
@@ -182,7 +178,7 @@ async def main():
logging.warning(f"Failed to scrape: {err}") logging.warning(f"Failed to scrape: {err}")
else: else:
logging.info("ROM scanning completed with no metadata errors") logging.info("ROM scanning completed with no metadata errors")
except Exception as e: except Exception as e:
logging.error(f"ROM scanning failed with error: {e}") logging.error(f"ROM scanning failed with error: {e}")
raise raise

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:
@@ -74,6 +75,12 @@ 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 # Ensure config directory exists
if not self.path.parent.exists(): if not self.path.parent.exists():
@@ -104,6 +111,7 @@ class Config:
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_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.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 # 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 == "":
@@ -118,5 +126,6 @@ class Config:
if secrets: if secrets:
self.igdb_api_key = secrets.get("IGDB_SECRET_KEY", self.igdb_api_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) 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()
return self return self

View File

@@ -1,15 +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 logging
import subprocess 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
@@ -22,6 +23,8 @@ 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
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)
@@ -51,6 +54,10 @@ engine = create_engine(
) )
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class GameData(BaseModel):
filename: str
url: str
# Enable WAL mode for better concurrency during startup # Enable WAL mode for better concurrency during startup
try: try:
with engine.connect() as conn: with engine.connect() as conn:
@@ -627,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,
@@ -1639,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

@@ -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 %}