Iniital release of DosVault.
This commit is contained in:
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
159
src/__main__.py
Executable file
159
src/__main__.py
Executable file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from libs.config import Config
|
||||
from libs.database import (Base, ingest_roms, get_existing_rom_paths)
|
||||
from libs.objects import Metadata, Game, Roms
|
||||
from libs.functions import extract_year_from_title, clean_title, download_image, get_image_filename
|
||||
from libs.apis import Credentials, IGDB
|
||||
from libs.logging import get_log_manager
|
||||
|
||||
config = Config()
|
||||
token = Credentials(config).authenticate()
|
||||
scrape_errors: List[str] = []
|
||||
|
||||
async def scrape_metadata(title: str, session: aiohttp.ClientSession) -> Metadata:
|
||||
igdb_response = IGDB(token)
|
||||
md = Metadata()
|
||||
igdb_response = IGDB(token).search_game_by_title(clean_title(title))
|
||||
try:
|
||||
if igdb_response[0]:
|
||||
game_data = igdb_response[0]
|
||||
try: md.title = game_data.get('name', title)
|
||||
except: md.title = title
|
||||
try: md.description = game_data.get('summary')
|
||||
except: md.description = None
|
||||
try: md.year = game_data.get('first_release_date')
|
||||
except: md.year = extract_year_from_title(title)
|
||||
try: md.developer = game_data.get('involved_companies', "")[0].get('company', "").get('name')
|
||||
except: md.developer = None
|
||||
try: md.publisher = game_data.get('involved_companies', "")[0].get('company', "").get('name')
|
||||
except: md.publisher = None
|
||||
try: md.genre = [genre['name'] for genre in game_data.get('genres', [])]
|
||||
except: md.genre = []
|
||||
try: md.players = game_data.get('player_perspectives', 1)[0]
|
||||
except: md.players = 1
|
||||
try:
|
||||
cover_data = game_data.get('cover')
|
||||
if cover_data and cover_data.get('image_id'):
|
||||
md.cover_image = IGDB.build_cover_url(cover_data['image_id'], 'cover_big')
|
||||
|
||||
# Download cover image locally
|
||||
cover_filename = get_image_filename(md.cover_image, title, 'cover')
|
||||
cover_path = config.images_path / cover_filename
|
||||
if await download_image(md.cover_image, cover_path, session):
|
||||
md.cover_image_path = cover_path
|
||||
else:
|
||||
md.cover_image = None
|
||||
md.cover_image_path = None
|
||||
except:
|
||||
md.cover_image = None
|
||||
md.cover_image_path = None
|
||||
try:
|
||||
artworks = game_data.get('artworks', [])
|
||||
if artworks and artworks[0].get('image_id'):
|
||||
md.screenshot = IGDB.build_cover_url(artworks[0]['image_id'], 'screenshot_med')
|
||||
|
||||
# Download screenshot locally
|
||||
screenshot_filename = get_image_filename(md.screenshot, title, 'screenshot')
|
||||
screenshot_path = config.images_path / screenshot_filename
|
||||
if await download_image(md.screenshot, screenshot_path, session):
|
||||
md.screenshot_path = screenshot_path
|
||||
else:
|
||||
md.screenshot = None
|
||||
md.screenshot_path = None
|
||||
except:
|
||||
md.screenshot = None
|
||||
md.screenshot_path = None
|
||||
try: md.tags = [theme['name'] for theme in game_data.get('themes', [])]
|
||||
except: md.tags = []
|
||||
except IndexError:
|
||||
pass
|
||||
return md
|
||||
|
||||
async def make_romlist(dir: Optional[Path] = None, roms: Optional[Roms] = None) -> Roms:
|
||||
romList: Roms = roms if roms else Roms()
|
||||
rompath: Path = dir if dir else config.rom_path
|
||||
|
||||
for pointer in rompath.rglob("*"):
|
||||
if pointer.is_file():
|
||||
title = pointer.stem
|
||||
romList.list.append(Game(title=title, path=pointer, metadata=Metadata()))
|
||||
return romList
|
||||
|
||||
|
||||
async def inject_metadata(roms: Roms) -> Roms:
|
||||
sem = asyncio.Semaphore(4) # run up to 4 concurrent scrapes
|
||||
results = [None] * len(roms.list)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async def _job(i: int, game):
|
||||
async with sem:
|
||||
try:
|
||||
await asyncio.sleep(0.25) # keep your throttle
|
||||
md = await scrape_metadata(game.title, session)
|
||||
except ValueError:
|
||||
scrape_errors.append(game.title)
|
||||
md = Metadata(title=game.title, year=extract_year_from_title(game.title))
|
||||
# print each item as its done to the top of the screen
|
||||
results[i] = md
|
||||
print("\033[F\033[K", end='')
|
||||
for err in scrape_errors[-5:]:
|
||||
print(f"Error: {err}")
|
||||
print(f"Scraped: {game.title} # {i+1}/{len(roms.list)}")
|
||||
|
||||
tasks = [asyncio.create_task(_job(i, game)) for i, game in enumerate(roms.list)]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
for game, md in zip(roms.list, results):
|
||||
game.metadata = md
|
||||
|
||||
return roms
|
||||
|
||||
async def filter_new_roms(romlist: Roms, session: Session) -> Roms:
|
||||
existing_paths = get_existing_rom_paths(session)
|
||||
new_roms = Roms()
|
||||
|
||||
for game in romlist.list:
|
||||
if game.path.resolve() not in existing_paths:
|
||||
new_roms.list.append(game)
|
||||
|
||||
print(f"Found {len(romlist.list)} total ROMs")
|
||||
print(f"Found {len(existing_paths)} existing ROMs in database")
|
||||
print(f"Will scrape {len(new_roms.list)} new ROMs")
|
||||
|
||||
return new_roms
|
||||
|
||||
async def main():
|
||||
url = f"sqlite+pysqlite:///{config.database_path}"
|
||||
engine = create_engine(url, future=True)
|
||||
# Database tables are now managed by migrations
|
||||
# Base.metadata.create_all(engine)
|
||||
|
||||
with Session(engine) as s:
|
||||
romlist = await make_romlist()
|
||||
new_romlist = await filter_new_roms(romlist, s)
|
||||
|
||||
if new_romlist.list:
|
||||
new_romlist = await inject_metadata(new_romlist)
|
||||
ingest_roms(new_romlist, s)
|
||||
else:
|
||||
print("No new ROMs to scrape!")
|
||||
|
||||
print("Done\nError list:")
|
||||
for err in scrape_errors:
|
||||
print(f" - {err}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Initialize logging
|
||||
get_log_manager()
|
||||
logging.info("Starting DosVault ROM scraper")
|
||||
asyncio.run(main())
|
||||
|
||||
323
src/backfill_images.py
Normal file
323
src/backfill_images.py
Normal file
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Backfill script to download images for existing games in the database.
|
||||
This script finds games that have remote image URLs but no local image files,
|
||||
and downloads them with proper error handling and progress tracking.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import create_engine, select, func
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
try:
|
||||
from libs.config import Config
|
||||
from libs.database import Game_table, Metadata_table
|
||||
from libs.functions import download_image, get_image_filename
|
||||
from libs.apis import IGDB
|
||||
except ImportError:
|
||||
import sys
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
from libs.config import Config
|
||||
from libs.database import Game_table, Metadata_table
|
||||
from libs.functions import download_image, get_image_filename
|
||||
from libs.apis import IGDB
|
||||
|
||||
class ImageBackfillManager:
|
||||
def __init__(self):
|
||||
self.config = Config()
|
||||
self.engine = create_engine(f"sqlite+pysqlite:///{self.config.database_path}", future=True)
|
||||
self.failed_downloads: List[str] = []
|
||||
self.successful_downloads: int = 0
|
||||
|
||||
def get_image_url(self, image_data: str, image_type: str = 'cover_big') -> Optional[str]:
|
||||
"""Convert image ID or URL to full URL."""
|
||||
if not image_data:
|
||||
return None
|
||||
|
||||
# If it's already a full URL, return as-is
|
||||
if image_data.startswith('http'):
|
||||
return image_data
|
||||
|
||||
# Skip old numeric-only image IDs (from old IGDB API) - they're no longer valid
|
||||
if image_data.isdigit():
|
||||
print(f" ⚠️ Skipping old numeric image ID: {image_data}")
|
||||
return None
|
||||
|
||||
# New IGDB image IDs are alphanumeric (e.g., 'co3ws0')
|
||||
if len(image_data) > 0 and not image_data.isspace():
|
||||
return IGDB.build_cover_url(image_data, image_type)
|
||||
|
||||
return None
|
||||
|
||||
def get_games_needing_images(self, limit: Optional[int] = None) -> List[Game_table]:
|
||||
"""Get games that have remote image URLs but no local image files."""
|
||||
with Session(self.engine) as session:
|
||||
stmt = (
|
||||
select(Game_table)
|
||||
.join(Metadata_table)
|
||||
.options(selectinload(Game_table.metadata_obj)) # Eager load relationships
|
||||
.where(
|
||||
(
|
||||
# Has cover image URL but no local cover path
|
||||
(Metadata_table.cover_image.is_not(None)) &
|
||||
(Metadata_table.cover_image_path.is_(None))
|
||||
) | (
|
||||
# Has screenshot URL but no local screenshot path
|
||||
(Metadata_table.screenshot.is_not(None)) &
|
||||
(Metadata_table.screenshot_path.is_(None))
|
||||
)
|
||||
)
|
||||
.order_by(Game_table.title)
|
||||
)
|
||||
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
# Load the objects with eager loading
|
||||
games = session.scalars(stmt).all()
|
||||
return games
|
||||
|
||||
def get_stats(self):
|
||||
"""Get statistics about images in the database."""
|
||||
with Session(self.engine) as session:
|
||||
total_games = session.scalar(select(func.count(Game_table.id)))
|
||||
|
||||
games_with_cover_urls = session.scalar(
|
||||
select(func.count(Metadata_table.id))
|
||||
.where(Metadata_table.cover_image.is_not(None))
|
||||
)
|
||||
|
||||
games_with_local_covers = session.scalar(
|
||||
select(func.count(Metadata_table.id))
|
||||
.where(Metadata_table.cover_image_path.is_not(None))
|
||||
)
|
||||
|
||||
games_with_screenshot_urls = session.scalar(
|
||||
select(func.count(Metadata_table.id))
|
||||
.where(Metadata_table.screenshot.is_not(None))
|
||||
)
|
||||
|
||||
games_with_local_screenshots = session.scalar(
|
||||
select(func.count(Metadata_table.id))
|
||||
.where(Metadata_table.screenshot_path.is_not(None))
|
||||
)
|
||||
|
||||
return {
|
||||
'total_games': total_games,
|
||||
'games_with_cover_urls': games_with_cover_urls,
|
||||
'games_with_local_covers': games_with_local_covers,
|
||||
'games_with_screenshot_urls': games_with_screenshot_urls,
|
||||
'games_with_local_screenshots': games_with_local_screenshots,
|
||||
}
|
||||
|
||||
async def download_images_for_game(self, game: Game_table, session: aiohttp.ClientSession) -> dict:
|
||||
"""Download images for a single game."""
|
||||
result = {
|
||||
'game_title': game.title,
|
||||
'cover_success': False,
|
||||
'screenshot_success': False,
|
||||
'cover_path': None,
|
||||
'screenshot_path': None,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
metadata = game.metadata_obj
|
||||
if not metadata:
|
||||
result['errors'].append('No metadata found')
|
||||
return result
|
||||
|
||||
# Download cover image if URL exists but no local file
|
||||
if metadata.cover_image and not metadata.cover_image_path:
|
||||
try:
|
||||
cover_url = self.get_image_url(metadata.cover_image, 'cover_big')
|
||||
if cover_url:
|
||||
cover_filename = get_image_filename(cover_url, game.title, 'cover')
|
||||
cover_path = self.config.images_path / cover_filename
|
||||
|
||||
if await download_image(cover_url, cover_path, session):
|
||||
result['cover_success'] = True
|
||||
result['cover_path'] = cover_path
|
||||
else:
|
||||
result['errors'].append(f'Failed to download cover: {cover_url}')
|
||||
else:
|
||||
result['errors'].append(f'Invalid cover image data: {metadata.cover_image}')
|
||||
except Exception as e:
|
||||
result['errors'].append(f'Cover download error: {str(e)}')
|
||||
|
||||
# Download screenshot if URL exists but no local file
|
||||
if metadata.screenshot and not metadata.screenshot_path:
|
||||
try:
|
||||
screenshot_url = self.get_image_url(metadata.screenshot, 'screenshot_med')
|
||||
if screenshot_url:
|
||||
screenshot_filename = get_image_filename(screenshot_url, game.title, 'screenshot')
|
||||
screenshot_path = self.config.images_path / screenshot_filename
|
||||
|
||||
if await download_image(screenshot_url, screenshot_path, session):
|
||||
result['screenshot_success'] = True
|
||||
result['screenshot_path'] = screenshot_path
|
||||
else:
|
||||
result['errors'].append(f'Failed to download screenshot: {screenshot_url}')
|
||||
else:
|
||||
result['errors'].append(f'Invalid screenshot image data: {metadata.screenshot}')
|
||||
except Exception as e:
|
||||
result['errors'].append(f'Screenshot download error: {str(e)}')
|
||||
|
||||
return result
|
||||
|
||||
async def process_batch(self, games: List[Game_table], batch_size: int = 50):
|
||||
"""Process a batch of games with concurrent downloads."""
|
||||
semaphore = asyncio.Semaphore(4) # Limit concurrent downloads
|
||||
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
||||
async def download_with_semaphore(game):
|
||||
async with semaphore:
|
||||
await asyncio.sleep(0.1) # Small delay to be respectful
|
||||
return await self.download_images_for_game(game, session)
|
||||
|
||||
# Process in batches to avoid overwhelming the database
|
||||
for i in range(0, len(games), batch_size):
|
||||
batch = games[i:i + batch_size]
|
||||
print(f"\nProcessing batch {i//batch_size + 1} ({len(batch)} games)...")
|
||||
|
||||
# Download images concurrently for this batch
|
||||
tasks = [download_with_semaphore(game) for game in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Update database with successful downloads
|
||||
with Session(self.engine) as db_session:
|
||||
for game, result in zip(batch, results):
|
||||
if isinstance(result, Exception):
|
||||
print(f" ✗ {game.title}: {str(result)}")
|
||||
self.failed_downloads.append(f"{game.title}: {str(result)}")
|
||||
continue
|
||||
|
||||
# Update metadata with local paths
|
||||
if result['cover_success']:
|
||||
game.metadata_obj.cover_image_path = result['cover_path']
|
||||
self.successful_downloads += 1
|
||||
|
||||
if result['screenshot_success']:
|
||||
game.metadata_obj.screenshot_path = result['screenshot_path']
|
||||
self.successful_downloads += 1
|
||||
|
||||
# Show progress
|
||||
status = []
|
||||
if result['cover_success']:
|
||||
status.append('cover ✓')
|
||||
if result['screenshot_success']:
|
||||
status.append('screenshot ✓')
|
||||
if result['errors']:
|
||||
status.extend([f"error: {err}" for err in result['errors']])
|
||||
|
||||
print(f" {game.title}: {', '.join(status) if status else 'no images needed'}")
|
||||
|
||||
# Commit batch
|
||||
db_session.commit()
|
||||
print(f" Batch committed to database")
|
||||
|
||||
async def run(self, limit: Optional[int] = None, dry_run: bool = False):
|
||||
"""Run the image backfill process."""
|
||||
print("🖼️ ROM Image Backfill Tool")
|
||||
print("=" * 50)
|
||||
|
||||
# Show current statistics
|
||||
stats = self.get_stats()
|
||||
print(f"Database Statistics:")
|
||||
print(f" Total games: {stats['total_games']}")
|
||||
print(f" Games with cover URLs: {stats['games_with_cover_urls']}")
|
||||
print(f" Games with local covers: {stats['games_with_local_covers']}")
|
||||
print(f" Games with screenshot URLs: {stats['games_with_screenshot_urls']}")
|
||||
print(f" Games with local screenshots: {stats['games_with_local_screenshots']}")
|
||||
|
||||
# Use session context for all operations
|
||||
with Session(self.engine) as session:
|
||||
# Get games that need images within the session
|
||||
stmt = (
|
||||
select(Game_table)
|
||||
.join(Metadata_table)
|
||||
.options(selectinload(Game_table.metadata_obj))
|
||||
.where(
|
||||
(
|
||||
# Has cover image URL but no local cover path
|
||||
(Metadata_table.cover_image.is_not(None)) &
|
||||
(Metadata_table.cover_image_path.is_(None))
|
||||
) | (
|
||||
# Has screenshot URL but no local screenshot path
|
||||
(Metadata_table.screenshot.is_not(None)) &
|
||||
(Metadata_table.screenshot_path.is_(None))
|
||||
)
|
||||
)
|
||||
.order_by(Game_table.title)
|
||||
)
|
||||
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
games = session.scalars(stmt).all()
|
||||
print(f"\nFound {len(games)} games needing image downloads")
|
||||
|
||||
if not games:
|
||||
print("✅ All games already have local images!")
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
print("\n🔍 DRY RUN - showing first 10 games that would be processed:")
|
||||
for i, game in enumerate(games[:10]):
|
||||
metadata = game.metadata_obj
|
||||
print(f" {i+1}. {game.title}")
|
||||
if metadata.cover_image and not metadata.cover_image_path:
|
||||
cover_url = self.get_image_url(metadata.cover_image, 'cover_big')
|
||||
print(f" Cover: {cover_url or metadata.cover_image}")
|
||||
if metadata.screenshot and not metadata.screenshot_path:
|
||||
screenshot_url = self.get_image_url(metadata.screenshot, 'screenshot_med')
|
||||
print(f" Screenshot: {screenshot_url or metadata.screenshot}")
|
||||
return
|
||||
|
||||
# Confirm before proceeding
|
||||
proceed = input(f"\nDownload images for {len(games)} games? [y/N]: ").strip().lower()
|
||||
if proceed != 'y':
|
||||
print("Cancelled.")
|
||||
return
|
||||
|
||||
# Process the games
|
||||
await self.process_batch(games)
|
||||
|
||||
# Show final results
|
||||
print(f"\n✅ Backfill Complete!")
|
||||
print(f" Successfully downloaded: {self.successful_downloads} images")
|
||||
print(f" Failed downloads: {len(self.failed_downloads)}")
|
||||
|
||||
if self.failed_downloads:
|
||||
print(f"\nFailed Downloads:")
|
||||
for failure in self.failed_downloads[:10]: # Show first 10
|
||||
print(f" - {failure}")
|
||||
if len(self.failed_downloads) > 10:
|
||||
print(f" ... and {len(self.failed_downloads) - 10} more")
|
||||
|
||||
async def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Download images for existing ROM entries")
|
||||
parser.add_argument('--limit', type=int, help='Limit number of games to process')
|
||||
parser.add_argument('--dry-run', action='store_true', help='Show what would be done without downloading')
|
||||
parser.add_argument('--stats-only', action='store_true', help='Show statistics only')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
manager = ImageBackfillManager()
|
||||
|
||||
if args.stats_only:
|
||||
stats = manager.get_stats()
|
||||
print("Database Statistics:")
|
||||
for key, value in stats.items():
|
||||
print(f" {key}: {value}")
|
||||
return
|
||||
|
||||
await manager.run(limit=args.limit, dry_run=args.dry_run)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
44
src/create_admin.py
Executable file
44
src/create_admin.py
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from libs.config import Config
|
||||
from libs.database import Base, User_table, UserRole
|
||||
from libs.auth import AuthManager
|
||||
import sys
|
||||
|
||||
def create_admin_user():
|
||||
config = Config()
|
||||
engine = create_engine(f"sqlite+pysqlite:///{config.database_path}")
|
||||
# Database tables are now managed by migrations
|
||||
# Base.metadata.create_all(bind=engine)
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
# Check if admin user exists
|
||||
existing_admin = db.query(User_table).filter(User_table.role == UserRole.SUPER.value).first()
|
||||
if existing_admin:
|
||||
print(f"Admin user already exists: {existing_admin.username}")
|
||||
return
|
||||
|
||||
username = input("Enter admin username: ").strip()
|
||||
email = input("Enter admin email: ").strip()
|
||||
password = input("Enter admin password: ").strip()
|
||||
|
||||
if not username or not email or not password:
|
||||
print("All fields are required!")
|
||||
sys.exit(1)
|
||||
|
||||
# Check if username exists
|
||||
existing_user = db.query(User_table).filter(User_table.username == username).first()
|
||||
if existing_user:
|
||||
print("Username already exists!")
|
||||
sys.exit(1)
|
||||
|
||||
admin_user = AuthManager.create_user(db, username, email, password, UserRole.SUPER.value)
|
||||
print(f"Admin user created successfully: {admin_user.username}")
|
||||
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_admin_user()
|
||||
0
src/libs/__init__.py
Normal file
0
src/libs/__init__.py
Normal file
101
src/libs/apis.py
Normal file
101
src/libs/apis.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import requests
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from .config import Config
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class URLS(Enum):
|
||||
IGDB_URL = "https://api.igdb.com/v4"
|
||||
TWITCH_AUTH_URL = "https://id.twitch.tv/oauth2/token"
|
||||
IGDB_GAMES_ENDPOINT = IGDB_URL + "/games"
|
||||
IGDB_COVERS_ENDPOINT = IGDB_URL + "/covers"
|
||||
|
||||
@dataclass
|
||||
class Credentials:
|
||||
client_id: str
|
||||
client_secret: str
|
||||
access_token: str|None = None
|
||||
expiry: int|None = None
|
||||
token_type: str|None = None
|
||||
|
||||
def get_credentials(self) -> Dict:
|
||||
auth_url = URLS.TWITCH_AUTH_URL.value+f"?client_id={self.client_id}&client_secret={self.client_secret}&grant_type=client_credentials"
|
||||
resp = requests.post(auth_url)
|
||||
if not resp.status_code == 200:
|
||||
raise ValueError("Failed to obtain access token from Twitch")
|
||||
else:
|
||||
return resp.json()
|
||||
|
||||
def authenticate(self) -> 'Credentials':
|
||||
credentials: Dict = self.get_credentials()
|
||||
self.access_token = credentials['access_token']
|
||||
self.expiry = credentials['expires_in']
|
||||
self.token_type = credentials['token_type']
|
||||
if not self.access_token:
|
||||
raise ValueError("Failed to obtain access token")
|
||||
return self
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.client_id = config.igdb_client_id
|
||||
self.client_secret = config.igdb_api_key
|
||||
|
||||
|
||||
class IGDB:
|
||||
def __init__(self, credentials: Credentials):
|
||||
self.client_id = credentials.client_id
|
||||
self.access_token = credentials.access_token
|
||||
self.token_type = credentials.token_type
|
||||
if not self.access_token:
|
||||
raise ValueError("Access token is not set. Please authenticate first.")
|
||||
|
||||
def headers(self) -> Dict:
|
||||
if not self.access_token:
|
||||
raise ValueError("Access token is not set. Please authenticate first.")
|
||||
return {
|
||||
"Client-ID": self.client_id,
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
}
|
||||
|
||||
def search_game_by_title(self, query: str) -> Dict:
|
||||
if not self.access_token:
|
||||
raise ValueError("Access token is not set. Please authenticate first.")
|
||||
search_url = URLS.IGDB_GAMES_ENDPOINT.value
|
||||
headers = self.headers()
|
||||
# Request full cover and artwork data with expanded fields
|
||||
data = f"""search "{query}"; fields name,summary,first_release_date,rating,platforms.name,genres.name,involved_companies.company.name,cover.image_id,artworks.image_id,themes.name,player_perspectives,id; where platforms = (13); limit 10;"""
|
||||
resp = requests.post(search_url, headers=headers, data=data)
|
||||
if resp.status_code != 200:
|
||||
raise ValueError(f"Failed to search games: {resp.status_code} - {resp.text}")
|
||||
return resp.json()
|
||||
|
||||
def get_cover_details(self, cover_id: int) -> Dict:
|
||||
"""Get cover details from IGDB by cover ID"""
|
||||
if not self.access_token:
|
||||
raise ValueError("Access token is not set. Please authenticate first.")
|
||||
covers_url = URLS.IGDB_COVERS_ENDPOINT.value
|
||||
headers = self.headers()
|
||||
data = f"""fields image_id,url,height,width,game; where id = {cover_id};"""
|
||||
resp = requests.post(covers_url, headers=headers, data=data)
|
||||
if resp.status_code != 200:
|
||||
raise ValueError(f"Failed to get cover details: {resp.status_code} - {resp.text}")
|
||||
return resp.json()
|
||||
|
||||
def get_covers_by_game_id(self, game_id: int) -> Dict:
|
||||
"""Get all covers for a specific game ID"""
|
||||
if not self.access_token:
|
||||
raise ValueError("Access token is not set. Please authenticate first.")
|
||||
covers_url = URLS.IGDB_COVERS_ENDPOINT.value
|
||||
headers = self.headers()
|
||||
data = f"""fields image_id,url,height,width; where game = {game_id};"""
|
||||
resp = requests.post(covers_url, headers=headers, data=data)
|
||||
if resp.status_code != 200:
|
||||
raise ValueError(f"Failed to get covers for game: {resp.status_code} - {resp.text}")
|
||||
return resp.json()
|
||||
|
||||
@staticmethod
|
||||
def build_cover_url(image_id: str, size: str = "cover_big") -> str:
|
||||
"""Build IGDB cover URL from image_id
|
||||
Size options: thumb, cover_small, screenshot_med, cover_big, logo_med, screenshot_big, screenshot_huge, thumb, micro, 720p, 1080p
|
||||
"""
|
||||
return f"https://images.igdb.com/igdb/image/upload/t_{size}/{image_id}.jpg"
|
||||
74
src/libs/auth.py
Normal file
74
src/libs/auth.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from passlib.context import CryptContext
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
from .database import User_table, UserRole
|
||||
|
||||
SECRET_KEY = "your-secret-key-change-this-in-production"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
class AuthManager:
|
||||
@staticmethod
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
@staticmethod
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
@staticmethod
|
||||
def verify_token(token: str) -> Optional[str]:
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
return None
|
||||
return username
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def authenticate_user(session: Session, username: str, password: str) -> Optional[User_table]:
|
||||
user = session.scalar(select(User_table).where(User_table.username == username))
|
||||
if not user:
|
||||
return None
|
||||
if not AuthManager.verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_username(session: Session, username: str) -> Optional[User_table]:
|
||||
return session.scalar(select(User_table).where(User_table.username == username))
|
||||
|
||||
@staticmethod
|
||||
def create_user(session: Session, username: str, email: str, password: str, role: str = UserRole.NORMAL.value) -> User_table:
|
||||
hashed_password = AuthManager.get_password_hash(password)
|
||||
user = User_table(
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=hashed_password,
|
||||
role=role
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
113
src/libs/config.py
Normal file
113
src/libs/config.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict
|
||||
import json
|
||||
import os
|
||||
|
||||
# Check for environment variable override (used in Docker)
|
||||
if os.getenv("DOSFRONTEND_CONFIG_DIR"):
|
||||
DOSFRONTEND_CONFIG_DIR: Path = Path(os.getenv("DOSFRONTEND_CONFIG_DIR"))
|
||||
else:
|
||||
# Default to XDG config directory for regular installations
|
||||
XDG_CONFIG_HOME: Path = Path(Path.home()).joinpath(".config")
|
||||
DOSFRONTEND_CONFIG_DIR: Path = XDG_CONFIG_HOME.joinpath("dosfrontend")
|
||||
|
||||
DOSFRONTEND_CONFIG_FILE: Path = DOSFRONTEND_CONFIG_DIR.joinpath("config.json")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
path: Path = DOSFRONTEND_CONFIG_FILE
|
||||
rom_path: Path = DOSFRONTEND_CONFIG_DIR.joinpath("roms")
|
||||
metadata_path: Path = DOSFRONTEND_CONFIG_DIR.joinpath("metadata")
|
||||
database_path: Path = DOSFRONTEND_CONFIG_DIR.joinpath("roms.db")
|
||||
images_path: Path = DOSFRONTEND_CONFIG_DIR.joinpath("images")
|
||||
host: str = "localhost"
|
||||
port: int = 8080
|
||||
websocket_port: int = 8081
|
||||
igdb_api_key: str = ""
|
||||
igdb_client_id: str = ""
|
||||
|
||||
def __init__(self, path: Optional[Path] = None):
|
||||
if path:
|
||||
self.path = path
|
||||
self.load()
|
||||
|
||||
def load_env_secrets(self) -> Dict[str, str] | None:
|
||||
secrets: Dict[str, str] = {}
|
||||
igdb_api_key = os.getenv("IGDB_SECRET_KEY")
|
||||
igdb_client_id = os.getenv("IGDB_CLIENT_ID")
|
||||
if not igdb_api_key or not igdb_client_id:
|
||||
file_path: Path = Path(__file__)
|
||||
env_path: Path = file_path.parent.parent.parent.joinpath(".env")
|
||||
if not env_path.exists():
|
||||
return
|
||||
else:
|
||||
with env_path.open('r') as f:
|
||||
for line in f:
|
||||
if line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.strip().split("=", 1)
|
||||
key, value = key.strip(), value.strip('"').strip("'")
|
||||
secrets[key] = value
|
||||
f.close()
|
||||
if secrets.get("IGDB_SECRET_KEY") and secrets.get("IGDB_CLIENT_ID"):
|
||||
return secrets
|
||||
else: return None
|
||||
else:
|
||||
secrets = {
|
||||
"IGDB_SECRET_KEY": igdb_api_key,
|
||||
"IGDB_CLIENT_ID": igdb_client_id,
|
||||
}
|
||||
return secrets
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"rom_path": str(self.rom_path),
|
||||
"metadata_path": str(self.metadata_path),
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"websocket_port": self.websocket_port,
|
||||
"igdb_api_key": self.igdb_api_key,
|
||||
"igdb_client_id": self.igdb_client_id,
|
||||
}
|
||||
|
||||
def save(self):
|
||||
if not self.path.parent.exists():
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rom_path = input(f"Enter the path to your ROMs [{self.rom_path}] enter for default: ").strip()
|
||||
metadata_path = input(f"Enter the path to your metadata [{self.metadata_path}] enter for default: ").strip()
|
||||
self.rom_path = Path(rom_path) if rom_path else self.rom_path
|
||||
self.metadata_path = Path(metadata_path) if metadata_path else self.metadata_path
|
||||
if not self.rom_path.exists():
|
||||
self.rom_path.mkdir(parents=True, exist_ok=True)
|
||||
if not self.metadata_path.exists():
|
||||
self.metadata_path.mkdir(parents=True, exist_ok=True)
|
||||
if not self.images_path.exists():
|
||||
self.images_path.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.path, 'w') as f:
|
||||
json.dump(self.to_dict(), f, indent=4)
|
||||
f.close()
|
||||
|
||||
def load(self) -> "Config":
|
||||
if self.path.exists():
|
||||
with open(self.path, 'r') as f:
|
||||
data = json.load(f)
|
||||
self.rom_path = Path(data.get("rom_path", str(self.rom_path)))
|
||||
self.metadata_path = Path(data.get("metadata_path", str(self.metadata_path)))
|
||||
self.host = data.get("host", self.host)
|
||||
self.port = data.get("port", self.port)
|
||||
self.websocket_port = data.get("websocket_port", self.websocket_port)
|
||||
if self.igdb_api_key == "" or self.igdb_client_id == "":
|
||||
secrets = self.load_env_secrets()
|
||||
if secrets:
|
||||
self.igdb_api_key = secrets.get("IGDB_SECRET_KEY", "")
|
||||
self.igdb_client_id = secrets.get("IGDB_CLIENT_ID", "")
|
||||
f.close()
|
||||
self.save()
|
||||
return self
|
||||
f.close()
|
||||
else:
|
||||
self.save()
|
||||
self.load()
|
||||
return self
|
||||
241
src/libs/database.py
Normal file
241
src/libs/database.py
Normal file
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
from sqlalchemy import (
|
||||
String,
|
||||
Integer,
|
||||
ForeignKey,
|
||||
Table,
|
||||
Column,
|
||||
UniqueConstraint,
|
||||
MetaData,
|
||||
select,
|
||||
DateTime,
|
||||
Boolean
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
from .objects import Roms
|
||||
from .functions import extract_year_from_title
|
||||
|
||||
|
||||
# ---- Base (with naming convention; nice for Alembic) -------------------------
|
||||
convention = {
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
metadata = MetaData(naming_convention=convention)
|
||||
|
||||
|
||||
# ---- PathType to store pathlib.Path as TEXT ----------------------------------
|
||||
class PathType(TypeDecorator):
|
||||
impl = String
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
return None if value is None else str(value)
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
return None if value is None else Path(value)
|
||||
|
||||
|
||||
# ---- Association tables (use Column, not mapped_column) ----------------------
|
||||
metadata_tags = Table(
|
||||
"metadata_tags",
|
||||
Base.metadata,
|
||||
Column("metadata_id", ForeignKey("metadata.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("tag_id", ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
|
||||
UniqueConstraint("metadata_id", "tag_id"),
|
||||
)
|
||||
|
||||
metadata_genres = Table(
|
||||
"metadata_genres",
|
||||
Base.metadata,
|
||||
Column("metadata_id", ForeignKey("metadata.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("genre_id", ForeignKey("genre.id", ondelete="CASCADE"), primary_key=True),
|
||||
UniqueConstraint("metadata_id", "genre_id"),
|
||||
)
|
||||
|
||||
user_favorites = Table(
|
||||
"user_favorites",
|
||||
Base.metadata,
|
||||
Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("game_id", ForeignKey("game.id", ondelete="CASCADE"), primary_key=True),
|
||||
UniqueConstraint("user_id", "game_id"),
|
||||
)
|
||||
|
||||
|
||||
class UserRole(PyEnum):
|
||||
DEMO = "demo"
|
||||
NORMAL = "normal"
|
||||
SUPER = "super"
|
||||
|
||||
|
||||
class User_table(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
role: Mapped[str] = mapped_column(String(20), default=UserRole.NORMAL.value)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
favorites: Mapped[List["Game_table"]] = relationship(
|
||||
secondary=user_favorites,
|
||||
back_populates="favorited_by",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"User(id={self.id}, username={self.username!r}, role={self.role})"
|
||||
|
||||
|
||||
class Tags_table(Base):
|
||||
__tablename__ = "tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(30), unique=True, index=True)
|
||||
|
||||
games: Mapped[List["Metadata_table"]] = relationship(
|
||||
secondary=metadata_tags,
|
||||
back_populates="tags",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
class Genre_table(Base):
|
||||
__tablename__ = "genre"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(30), unique=True, index=True)
|
||||
|
||||
games: Mapped[List["Metadata_table"]] = relationship(
|
||||
secondary=metadata_genres,
|
||||
back_populates="genre",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
class Game_table(Base):
|
||||
__tablename__ = "game"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(66), index=True)
|
||||
path: Mapped[Path] = mapped_column(PathType(), unique=True, nullable=False)
|
||||
|
||||
metadata_obj: Mapped[Optional["Metadata_table"]] = relationship(
|
||||
back_populates="game",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
favorited_by: Mapped[List["User_table"]] = relationship(
|
||||
secondary=user_favorites,
|
||||
back_populates="favorites",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Game(id={self.id}, title={self.title!r}, path={str(self.path)!r})"
|
||||
|
||||
|
||||
class Metadata_table(Base):
|
||||
__tablename__ = "metadata"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
game_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("game.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
title: Mapped[str] = mapped_column(String(66))
|
||||
description: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
year: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
developer: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
publisher: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
players: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
cover_image: Mapped[Optional[str]] = mapped_column(String, nullable=True) # Remote URL
|
||||
screenshot: Mapped[Optional[str]] = mapped_column(String, nullable=True) # Remote URL
|
||||
cover_image_path: Mapped[Optional[Path]] = mapped_column(PathType(), nullable=True) # Local file path
|
||||
screenshot_path: Mapped[Optional[Path]] = mapped_column(PathType(), nullable=True) # Local file path
|
||||
|
||||
genre: Mapped[List[Genre_table]] = relationship(
|
||||
secondary=metadata_genres,
|
||||
back_populates="games",
|
||||
lazy="selectin",
|
||||
)
|
||||
tags: Mapped[List[Tags_table]] = relationship(
|
||||
secondary=metadata_tags,
|
||||
back_populates="games",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
game: Mapped["Game_table"] = relationship(back_populates="metadata_obj")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Metadata(id={self.id}, game_id={self.game_id}, title={self.title!r}, year={self.year})"
|
||||
|
||||
def _get_or_create_by_name(session: Session, model, name: str):
|
||||
obj = session.scalar(select(model).where(model.name == name))
|
||||
if obj is None:
|
||||
obj = model(name=name)
|
||||
session.add(obj)
|
||||
return obj
|
||||
|
||||
def get_existing_rom_paths(session: Session) -> set[Path]:
|
||||
return {game.path.resolve() for game in session.scalars(select(Game_table)).all()}
|
||||
|
||||
def ingest_roms(roms: Roms, session: Session, *, batch: int = 200) -> int:
|
||||
n = 0
|
||||
for g in roms.list:
|
||||
game = session.scalar(select(Game_table).where(Game_table.path == g.path))
|
||||
if game is None:
|
||||
game = Game_table(title=g.title, path=g.path)
|
||||
session.add(game)
|
||||
else:
|
||||
game.title = g.title
|
||||
mdto = g.metadata
|
||||
md = game.metadata_obj
|
||||
if md is None:
|
||||
md = Metadata_table(game=game, title=mdto.title or g.title)
|
||||
session.add(md)
|
||||
|
||||
md.title = mdto.title or g.title
|
||||
md.description = mdto.description
|
||||
md.year = mdto.year if mdto.year is not None else extract_year_from_title(md.title)
|
||||
md.developer = mdto.developer
|
||||
md.publisher = mdto.publisher
|
||||
md.players = mdto.players
|
||||
md.cover_image = mdto.cover_image
|
||||
md.screenshot = mdto.screenshot
|
||||
md.cover_image_path = mdto.cover_image_path
|
||||
md.screenshot_path = mdto.screenshot_path
|
||||
|
||||
try: genres = sorted({s.strip() for s in (mdto.genre or []) if s and s.strip()})
|
||||
except: genres = []
|
||||
try: tags = sorted({s.strip() for s in (mdto.tags or []) if s and s.strip()})
|
||||
except: tags = []
|
||||
|
||||
md.genre = [_get_or_create_by_name(session, Genre_table, name) for name in genres]
|
||||
md.tags = [_get_or_create_by_name(session, Tags_table, name) for name in tags]
|
||||
|
||||
n += 1
|
||||
if n % batch == 0:
|
||||
session.flush()
|
||||
|
||||
session.commit()
|
||||
return n
|
||||
|
||||
78
src/libs/functions.py
Normal file
78
src/libs/functions.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from typing import Optional
|
||||
import re
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
|
||||
YEAR_RE = re.compile(r"\((\d{4})\)")
|
||||
PARENS_RE = re.compile(r"\([^)]*\)")
|
||||
|
||||
def extract_year_from_title(title: Optional[str]) -> Optional[int]:
|
||||
if not title:
|
||||
return None
|
||||
m = YEAR_RE.search(title)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
def clean_title(title: str) -> str:
|
||||
# remove anything in (...) from the title
|
||||
cleaned = PARENS_RE.sub("", title)
|
||||
return " ".join(cleaned.split()).strip()
|
||||
|
||||
async def download_image(url: str, save_path: Path, session: aiohttp.ClientSession) -> bool:
|
||||
"""
|
||||
Download an image from URL and save it locally.
|
||||
|
||||
Args:
|
||||
url: The image URL to download
|
||||
save_path: Local path where to save the image
|
||||
session: aiohttp client session
|
||||
|
||||
Returns:
|
||||
bool: True if download was successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Create directory if it doesn't exist
|
||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
content = await response.read()
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(content)
|
||||
return True
|
||||
else:
|
||||
print(f"Failed to download {url}: HTTP {response.status}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error downloading {url}: {e}")
|
||||
return False
|
||||
|
||||
def get_image_filename(url: str, game_title: str, image_type: str) -> str:
|
||||
"""
|
||||
Generate a unique filename for an image based on game title and URL.
|
||||
|
||||
Args:
|
||||
url: The image URL
|
||||
game_title: The game title
|
||||
image_type: 'cover' or 'screenshot'
|
||||
|
||||
Returns:
|
||||
str: Generated filename
|
||||
"""
|
||||
# Create a hash of the URL to ensure uniqueness
|
||||
url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
|
||||
|
||||
# Clean game title for filename
|
||||
clean_name = re.sub(r'[^\w\-_\. ]', '', game_title)
|
||||
clean_name = re.sub(r'\s+', '_', clean_name).strip('_')
|
||||
|
||||
# Get file extension from URL
|
||||
try:
|
||||
ext = Path(url.split('?')[0]).suffix
|
||||
if not ext:
|
||||
ext = '.jpg' # Default extension
|
||||
except:
|
||||
ext = '.jpg'
|
||||
|
||||
return f"{clean_name}_{image_type}_{url_hash}{ext}"
|
||||
220
src/libs/logging.py
Normal file
220
src/libs/logging.py
Normal file
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python
|
||||
"""Logging configuration for DosVault application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
try:
|
||||
from .config import Config
|
||||
except ImportError:
|
||||
from config import Config
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""Custom JSON formatter for structured logging."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry = {
|
||||
'timestamp': datetime.fromtimestamp(record.created).isoformat(),
|
||||
'level': record.levelname,
|
||||
'module': record.name,
|
||||
'message': record.getMessage(),
|
||||
'filename': record.filename,
|
||||
'line_number': record.lineno,
|
||||
}
|
||||
|
||||
if record.exc_info:
|
||||
log_entry['traceback'] = self.formatException(record.exc_info)
|
||||
|
||||
return json.dumps(log_entry)
|
||||
|
||||
|
||||
class LogManager:
|
||||
"""Manages logging configuration and log file access."""
|
||||
|
||||
def __init__(self, config: Optional[Config] = None):
|
||||
self.config = config or Config()
|
||||
# Use the existing config directory structure
|
||||
self.log_dir = self.config.path.parent / "logs"
|
||||
self.log_dir.mkdir(exist_ok=True)
|
||||
|
||||
self.log_file = self.log_dir / "application.log"
|
||||
self.error_log_file = self.log_dir / "error.log"
|
||||
|
||||
self._setup_logging()
|
||||
|
||||
def _setup_logging(self):
|
||||
"""Configure logging handlers and formatters."""
|
||||
# Create root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.INFO)
|
||||
|
||||
# Clear existing handlers
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Console handler with simple format
|
||||
console_handler = logging.StreamHandler()
|
||||
console_formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
console_handler.setLevel(logging.INFO)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# File handler with JSON format
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
self.log_file,
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5
|
||||
)
|
||||
file_handler.setFormatter(JSONFormatter())
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# Error file handler
|
||||
error_handler = logging.handlers.RotatingFileHandler(
|
||||
self.error_log_file,
|
||||
maxBytes=5*1024*1024, # 5MB
|
||||
backupCount=3
|
||||
)
|
||||
error_formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s\n%(pathname)s:%(lineno)d\n'
|
||||
)
|
||||
error_handler.setFormatter(error_formatter)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
root_logger.addHandler(error_handler)
|
||||
|
||||
# Log startup
|
||||
logging.info("DosVault logging system initialized")
|
||||
|
||||
def get_recent_logs(self, limit: int = 1000, level_filter: Optional[str] = None, since: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get recent log entries from the log file."""
|
||||
logs = []
|
||||
|
||||
if not self.log_file.exists():
|
||||
return logs
|
||||
|
||||
try:
|
||||
# Parse the since timestamp if provided
|
||||
since_datetime = None
|
||||
if since:
|
||||
try:
|
||||
since_datetime = datetime.fromisoformat(since.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
logging.warning(f"Invalid since timestamp format: {since}")
|
||||
|
||||
with open(self.log_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
# Get the last 'limit*2' lines to ensure we have enough after filtering
|
||||
recent_lines = lines[-(limit*2):] if len(lines) > limit*2 else lines
|
||||
|
||||
for line in recent_lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
log_entry = json.loads(line)
|
||||
|
||||
# Apply time filter if specified
|
||||
if since_datetime:
|
||||
try:
|
||||
log_datetime = datetime.fromisoformat(log_entry['timestamp'])
|
||||
|
||||
# Handle timezone-aware/naive comparison
|
||||
if log_datetime.tzinfo is None and since_datetime.tzinfo is not None:
|
||||
# Make log_datetime timezone-aware (assume UTC)
|
||||
log_datetime = log_datetime.replace(tzinfo=timezone.utc)
|
||||
elif log_datetime.tzinfo is not None and since_datetime.tzinfo is None:
|
||||
# Make since_datetime timezone-aware (assume UTC)
|
||||
since_datetime = since_datetime.replace(tzinfo=timezone.utc)
|
||||
|
||||
if log_datetime <= since_datetime:
|
||||
continue
|
||||
except (ValueError, KeyError):
|
||||
pass # Skip time filtering for invalid timestamps
|
||||
|
||||
# Apply level filter if specified
|
||||
if level_filter and log_entry.get('level') != level_filter:
|
||||
continue
|
||||
|
||||
logs.append(log_entry)
|
||||
except json.JSONDecodeError:
|
||||
# Handle non-JSON log lines
|
||||
logs.append({
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'level': 'INFO',
|
||||
'module': 'system',
|
||||
'message': line
|
||||
})
|
||||
|
||||
# Sort by timestamp and limit results
|
||||
logs.sort(key=lambda x: x.get('timestamp', ''))
|
||||
logs = logs[-limit:] if len(logs) > limit else logs
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error reading log file: {e}")
|
||||
|
||||
return logs
|
||||
|
||||
def get_log_files(self) -> List[Dict[str, Any]]:
|
||||
"""Get information about available log files."""
|
||||
files = []
|
||||
|
||||
for log_file in self.log_dir.glob("*.log*"):
|
||||
try:
|
||||
stat = log_file.stat()
|
||||
files.append({
|
||||
'name': log_file.name,
|
||||
'path': str(log_file),
|
||||
'size': stat.st_size,
|
||||
'modified': datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting file info for {log_file}: {e}")
|
||||
|
||||
return sorted(files, key=lambda x: x['modified'], reverse=True)
|
||||
|
||||
def clear_old_logs(self, keep_days: int = 7) -> int:
|
||||
"""Clear log files older than specified days."""
|
||||
cleared_count = 0
|
||||
cutoff_time = datetime.now().timestamp() - (keep_days * 24 * 3600)
|
||||
|
||||
for log_file in self.log_dir.glob("*.log.*"): # Rotated logs only
|
||||
try:
|
||||
if log_file.stat().st_mtime < cutoff_time:
|
||||
log_file.unlink()
|
||||
cleared_count += 1
|
||||
logging.info(f"Cleared old log file: {log_file.name}")
|
||||
except Exception as e:
|
||||
logging.error(f"Error clearing log file {log_file}: {e}")
|
||||
|
||||
return cleared_count
|
||||
|
||||
def get_log_file_content(self, file_type: str = "application") -> Optional[Path]:
|
||||
"""Get the path to a specific log file for download."""
|
||||
if file_type == "application":
|
||||
return self.log_file if self.log_file.exists() else None
|
||||
elif file_type == "error":
|
||||
return self.error_log_file if self.error_log_file.exists() else None
|
||||
else:
|
||||
# Look for specific log file
|
||||
log_file = self.log_dir / f"{file_type}.log"
|
||||
return log_file if log_file.exists() else None
|
||||
|
||||
|
||||
# Global log manager instance - initialized lazily
|
||||
log_manager = None
|
||||
|
||||
def get_log_manager() -> LogManager:
|
||||
"""Get or create the global log manager instance."""
|
||||
global log_manager
|
||||
if log_manager is None:
|
||||
log_manager = LogManager()
|
||||
return log_manager
|
||||
29
src/libs/objects.py
Normal file
29
src/libs/objects.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
from pathlib import Path
|
||||
|
||||
@dataclass
|
||||
class Metadata:
|
||||
title: str = None
|
||||
description: Optional[str] = None
|
||||
year: Optional[int] = None
|
||||
developer: Optional[str] = None
|
||||
publisher: Optional[str] = None
|
||||
genre: Optional[List[str]] = field(default_factory=list)
|
||||
players: Optional[int] = None
|
||||
cover_image: Optional[str] = None # Remote URL
|
||||
screenshot: Optional[str] = None # Remote URL
|
||||
cover_image_path: Optional[Path] = None # Local file path
|
||||
screenshot_path: Optional[Path] = None # Local file path
|
||||
tags: Optional[List[str]] = field(default_factory=list)
|
||||
|
||||
@dataclass
|
||||
class Game:
|
||||
title: str
|
||||
path: Path
|
||||
metadata: Metadata|None = None
|
||||
|
||||
@dataclass
|
||||
class Roms:
|
||||
list: List[Game] = field(default_factory=list)
|
||||
|
||||
149
src/migrate.py
Executable file
149
src/migrate.py
Executable file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Database migration management script.
|
||||
"""
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from alembic.config import Config
|
||||
from alembic import command
|
||||
from alembic.script import ScriptDirectory
|
||||
from alembic.runtime.environment import EnvironmentContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
# Add current directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from libs.config import Config as AppConfig
|
||||
from libs.database import Base
|
||||
|
||||
def get_alembic_config():
|
||||
"""Get Alembic configuration object."""
|
||||
alembic_cfg = Config(str(Path(__file__).parent.parent / "alembic.ini"))
|
||||
app_config = AppConfig()
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", f"sqlite:///{app_config.database_path}")
|
||||
return alembic_cfg
|
||||
|
||||
def init_database():
|
||||
"""Initialize database tables without Alembic for first-time setup."""
|
||||
app_config = AppConfig()
|
||||
engine = create_engine(f"sqlite:///{app_config.database_path}")
|
||||
Base.metadata.create_all(engine)
|
||||
print(f"Database initialized at {app_config.database_path}")
|
||||
|
||||
def create_migration(message: str):
|
||||
"""Create a new migration file."""
|
||||
alembic_cfg = get_alembic_config()
|
||||
command.revision(alembic_cfg, message=message, autogenerate=True)
|
||||
print(f"Created migration: {message}")
|
||||
|
||||
def upgrade_database(revision: str = "head"):
|
||||
"""Upgrade database to a specific revision."""
|
||||
alembic_cfg = get_alembic_config()
|
||||
command.upgrade(alembic_cfg, revision)
|
||||
print(f"Database upgraded to {revision}")
|
||||
|
||||
def downgrade_database(revision: str):
|
||||
"""Downgrade database to a specific revision."""
|
||||
alembic_cfg = get_alembic_config()
|
||||
command.downgrade(alembic_cfg, revision)
|
||||
print(f"Database downgraded to {revision}")
|
||||
|
||||
def show_history():
|
||||
"""Show migration history."""
|
||||
alembic_cfg = get_alembic_config()
|
||||
command.history(alembic_cfg)
|
||||
|
||||
def show_current():
|
||||
"""Show current database revision."""
|
||||
alembic_cfg = get_alembic_config()
|
||||
command.current(alembic_cfg)
|
||||
|
||||
def stamp_database(revision: str = "head"):
|
||||
"""Mark the database as being at a specific revision without running migrations."""
|
||||
alembic_cfg = get_alembic_config()
|
||||
command.stamp(alembic_cfg, revision)
|
||||
print(f"Database stamped at {revision}")
|
||||
|
||||
def check_database_exists():
|
||||
"""Check if database and migration table exist."""
|
||||
app_config = AppConfig()
|
||||
db_path = Path(app_config.database_path)
|
||||
|
||||
if not db_path.exists():
|
||||
print("Database does not exist.")
|
||||
return False
|
||||
|
||||
# Check if alembic_version table exists
|
||||
engine = create_engine(f"sqlite:///{app_config.database_path}")
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if "alembic_version" not in tables:
|
||||
print("Database exists but is not under Alembic control.")
|
||||
return False
|
||||
|
||||
print("Database exists and is under Alembic control.")
|
||||
return True
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Database migration management")
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# Init command
|
||||
subparsers.add_parser('init', help='Initialize database (for first-time setup)')
|
||||
|
||||
# Stamp command
|
||||
stamp_parser = subparsers.add_parser('stamp', help='Mark database as being at a specific revision')
|
||||
stamp_parser.add_argument('revision', nargs='?', default='head', help='Revision to stamp (default: head)')
|
||||
|
||||
# Create migration command
|
||||
create_parser = subparsers.add_parser('create', help='Create a new migration')
|
||||
create_parser.add_argument('message', help='Migration message')
|
||||
|
||||
# Upgrade command
|
||||
upgrade_parser = subparsers.add_parser('upgrade', help='Upgrade database')
|
||||
upgrade_parser.add_argument('revision', nargs='?', default='head', help='Target revision (default: head)')
|
||||
|
||||
# Downgrade command
|
||||
downgrade_parser = subparsers.add_parser('downgrade', help='Downgrade database')
|
||||
downgrade_parser.add_argument('revision', help='Target revision')
|
||||
|
||||
# History command
|
||||
subparsers.add_parser('history', help='Show migration history')
|
||||
|
||||
# Current command
|
||||
subparsers.add_parser('current', help='Show current database revision')
|
||||
|
||||
# Check command
|
||||
subparsers.add_parser('check', help='Check database status')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
try:
|
||||
if args.command == 'init':
|
||||
init_database()
|
||||
elif args.command == 'stamp':
|
||||
stamp_database(args.revision)
|
||||
elif args.command == 'create':
|
||||
create_migration(args.message)
|
||||
elif args.command == 'upgrade':
|
||||
upgrade_database(args.revision)
|
||||
elif args.command == 'downgrade':
|
||||
downgrade_database(args.revision)
|
||||
elif args.command == 'history':
|
||||
show_history()
|
||||
elif args.command == 'current':
|
||||
show_current()
|
||||
elif args.command == 'check':
|
||||
check_database_exists()
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
274
src/refresh_covers.py
Executable file
274
src/refresh_covers.py
Executable file
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Script to refresh cover image metadata for games with old numeric image IDs.
|
||||
This will re-query IGDB for fresh image data and download the images locally.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import create_engine, select, func
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
try:
|
||||
from libs.config import Config
|
||||
from libs.database import Game_table, Metadata_table
|
||||
from libs.functions import download_image, get_image_filename, clean_title
|
||||
from libs.apis import Credentials, IGDB
|
||||
except ImportError:
|
||||
import sys
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
from libs.config import Config
|
||||
from libs.database import Game_table, Metadata_table
|
||||
from libs.functions import download_image, get_image_filename, clean_title
|
||||
from libs.apis import Credentials, IGDB
|
||||
|
||||
class CoverRefreshManager:
|
||||
def __init__(self):
|
||||
self.config = Config()
|
||||
self.engine = create_engine(f"sqlite+pysqlite:///{self.config.database_path}", future=True)
|
||||
|
||||
# Initialize IGDB API
|
||||
token = Credentials(self.config).authenticate()
|
||||
self.igdb = IGDB(token)
|
||||
|
||||
self.refreshed_count = 0
|
||||
self.download_success_count = 0
|
||||
self.failed_refreshes: List[str] = []
|
||||
|
||||
def get_games_with_old_image_ids(self, limit: Optional[int] = None) -> List[Game_table]:
|
||||
"""Get games that have old numeric image IDs."""
|
||||
with Session(self.engine) as session:
|
||||
stmt = (
|
||||
select(Game_table)
|
||||
.join(Metadata_table)
|
||||
.options(selectinload(Game_table.metadata_obj))
|
||||
.where(
|
||||
# Has old numeric image IDs (not alphanumeric)
|
||||
Metadata_table.cover_image.op('REGEXP')('^[0-9]+$')
|
||||
)
|
||||
.order_by(Game_table.title)
|
||||
)
|
||||
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
return session.scalars(stmt).all()
|
||||
|
||||
async def refresh_game_metadata(self, game: Game_table, session: aiohttp.ClientSession) -> dict:
|
||||
"""Refresh metadata for a single game and download images."""
|
||||
result = {
|
||||
'game_title': game.title,
|
||||
'api_success': False,
|
||||
'cover_updated': False,
|
||||
'cover_downloaded': False,
|
||||
'screenshot_updated': False,
|
||||
'screenshot_downloaded': False,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
try:
|
||||
# Search for fresh game data
|
||||
clean_title_text = clean_title(game.title)
|
||||
igdb_response = self.igdb.search_game_by_title(clean_title_text)
|
||||
|
||||
if not igdb_response or len(igdb_response) == 0:
|
||||
result['errors'].append('No IGDB results found')
|
||||
return result
|
||||
|
||||
game_data = igdb_response[0] # Take the first result
|
||||
result['api_success'] = True
|
||||
|
||||
metadata = game.metadata_obj
|
||||
if not metadata:
|
||||
result['errors'].append('No metadata object found')
|
||||
return result
|
||||
|
||||
# Update cover image if found
|
||||
cover_data = game_data.get('cover')
|
||||
if cover_data and cover_data.get('image_id'):
|
||||
new_cover_id = cover_data['image_id']
|
||||
new_cover_url = IGDB.build_cover_url(new_cover_id, 'cover_big')
|
||||
|
||||
# Update database with new image ID/URL
|
||||
metadata.cover_image = new_cover_id # Store the new ID
|
||||
result['cover_updated'] = True
|
||||
|
||||
# Download the image
|
||||
cover_filename = get_image_filename(new_cover_url, game.title, 'cover')
|
||||
cover_path = self.config.images_path / cover_filename
|
||||
|
||||
if await download_image(new_cover_url, cover_path, session):
|
||||
metadata.cover_image_path = cover_path
|
||||
result['cover_downloaded'] = True
|
||||
self.download_success_count += 1
|
||||
else:
|
||||
result['errors'].append(f'Failed to download cover: {new_cover_url}')
|
||||
|
||||
# Update screenshot if found
|
||||
artworks = game_data.get('artworks', [])
|
||||
if artworks and len(artworks) > 0 and artworks[0].get('image_id'):
|
||||
new_screenshot_id = artworks[0]['image_id']
|
||||
new_screenshot_url = IGDB.build_cover_url(new_screenshot_id, 'screenshot_med')
|
||||
|
||||
# Update database with new image ID/URL
|
||||
metadata.screenshot = new_screenshot_id # Store the new ID
|
||||
result['screenshot_updated'] = True
|
||||
|
||||
# Download the image
|
||||
screenshot_filename = get_image_filename(new_screenshot_url, game.title, 'screenshot')
|
||||
screenshot_path = self.config.images_path / screenshot_filename
|
||||
|
||||
if await download_image(new_screenshot_url, screenshot_path, session):
|
||||
metadata.screenshot_path = screenshot_path
|
||||
result['screenshot_downloaded'] = True
|
||||
self.download_success_count += 1
|
||||
else:
|
||||
result['errors'].append(f'Failed to download screenshot: {new_screenshot_url}')
|
||||
|
||||
if result['cover_updated'] or result['screenshot_updated']:
|
||||
self.refreshed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
result['errors'].append(f'API error: {str(e)}')
|
||||
|
||||
return result
|
||||
|
||||
async def process_batch(self, games: List[Game_table], batch_size: int = 20):
|
||||
"""Process games in batches with rate limiting."""
|
||||
semaphore = asyncio.Semaphore(2) # Lower concurrency for API calls
|
||||
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
||||
async def refresh_with_semaphore(game):
|
||||
async with semaphore:
|
||||
await asyncio.sleep(0.5) # Rate limiting - be respectful to IGDB API
|
||||
return await self.refresh_game_metadata(game, session)
|
||||
|
||||
# Process in smaller batches to avoid overwhelming the API
|
||||
for i in range(0, len(games), batch_size):
|
||||
batch = games[i:i + batch_size]
|
||||
print(f"\nProcessing batch {i//batch_size + 1} ({len(batch)} games)...")
|
||||
|
||||
# Process this batch
|
||||
tasks = [refresh_with_semaphore(game) for game in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Update database with results - need to reattach objects to new session
|
||||
with Session(self.engine) as db_session:
|
||||
for game, result in zip(batch, results):
|
||||
if isinstance(result, Exception):
|
||||
print(f" ✗ {game.title}: Exception - {str(result)}")
|
||||
self.failed_refreshes.append(f"{game.title}: {str(result)}")
|
||||
continue
|
||||
|
||||
# Reattach the game object to this session
|
||||
db_session.merge(game)
|
||||
|
||||
# Update the game's metadata directly
|
||||
if result.get('cover_path'):
|
||||
game.metadata_obj.cover_image_path = result['cover_path']
|
||||
if result.get('screenshot_path'):
|
||||
game.metadata_obj.screenshot_path = result['screenshot_path']
|
||||
|
||||
# Show progress
|
||||
status = []
|
||||
if result['api_success']:
|
||||
if result['cover_updated']:
|
||||
status.append('cover updated' + (' + downloaded' if result['cover_downloaded'] else ''))
|
||||
if result['screenshot_updated']:
|
||||
status.append('screenshot updated' + (' + downloaded' if result['screenshot_downloaded'] else ''))
|
||||
|
||||
if result['errors']:
|
||||
error_summary = '; '.join(result['errors'][:2]) # Show first 2 errors
|
||||
if len(result['errors']) > 2:
|
||||
error_summary += f' (+{len(result["errors"])-2} more)'
|
||||
status.append(f"errors: {error_summary}")
|
||||
self.failed_refreshes.append(f"{game.title}: {error_summary}")
|
||||
|
||||
print(f" {game.title}: {', '.join(status) if status else 'no changes'}")
|
||||
|
||||
# Commit batch
|
||||
db_session.commit()
|
||||
print(f" Batch committed to database")
|
||||
|
||||
async def run(self, limit: Optional[int] = None, dry_run: bool = False):
|
||||
"""Run the cover refresh process."""
|
||||
print("🔄 ROM Cover Refresh Tool")
|
||||
print("=" * 50)
|
||||
|
||||
# Get games with old image IDs
|
||||
with Session(self.engine) as session:
|
||||
# Use raw SQL for SQLite REGEXP (since SQLite's REGEXP isn't standard)
|
||||
stmt = (
|
||||
select(Game_table)
|
||||
.join(Metadata_table)
|
||||
.options(selectinload(Game_table.metadata_obj))
|
||||
.where(
|
||||
# Check if cover_image is purely numeric (old format)
|
||||
Metadata_table.cover_image.isnot(None) &
|
||||
~Metadata_table.cover_image.op('GLOB')('*[a-zA-Z]*') # No letters
|
||||
)
|
||||
.order_by(Game_table.title)
|
||||
)
|
||||
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
games = session.scalars(stmt).all()
|
||||
print(f"Found {len(games)} games with old numeric image IDs")
|
||||
|
||||
if not games:
|
||||
print("✅ No games need cover refresh!")
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
print("\n🔍 DRY RUN - showing first 10 games that would be processed:")
|
||||
for i, game in enumerate(games[:10]):
|
||||
metadata = game.metadata_obj
|
||||
print(f" {i+1}. {game.title}")
|
||||
print(f" Current cover ID: {metadata.cover_image}")
|
||||
if metadata.screenshot:
|
||||
print(f" Current screenshot ID: {metadata.screenshot}")
|
||||
return
|
||||
|
||||
# Show warning about API usage
|
||||
print(f"\n⚠️ This will make {len(games)} IGDB API calls.")
|
||||
print(" Be mindful of rate limits and API quotas.")
|
||||
|
||||
proceed = input(f"\nRefresh metadata for {len(games)} games? [y/N]: ").strip().lower()
|
||||
if proceed != 'y':
|
||||
print("Cancelled.")
|
||||
return
|
||||
|
||||
# Process the games
|
||||
await self.process_batch(games)
|
||||
|
||||
# Show final results
|
||||
print(f"\n✅ Refresh Complete!")
|
||||
print(f" Games with updated metadata: {self.refreshed_count}")
|
||||
print(f" Images successfully downloaded: {self.download_success_count}")
|
||||
print(f" Failed refreshes: {len(self.failed_refreshes)}")
|
||||
|
||||
if self.failed_refreshes:
|
||||
print(f"\nFailed Refreshes (first 10):")
|
||||
for failure in self.failed_refreshes[:10]:
|
||||
print(f" - {failure}")
|
||||
if len(self.failed_refreshes) > 10:
|
||||
print(f" ... and {len(self.failed_refreshes) - 10} more")
|
||||
|
||||
async def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Refresh cover metadata for games with old image IDs")
|
||||
parser.add_argument('--limit', type=int, help='Limit number of games to process')
|
||||
parser.add_argument('--dry-run', action='store_true', help='Show what would be done without processing')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
manager = CoverRefreshManager()
|
||||
await manager.run(limit=args.limit, dry_run=args.dry_run)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
1071
src/webapp.py
Executable file
1071
src/webapp.py
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user