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

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