#!/usr/bin/env python3 """Generate an HTML playtime report from a Lutris SQLite database.""" #################################################################################################### # Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space # # # # Permission to use, copy, modify, and/or distribute this software for any purpose with or without # # fee is hereby granted. # # # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS # # SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE # # AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES # # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, # # NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE # # OF THIS SOFTWARE. # #################################################################################################### import argparse import base64 import json import sqlite3 from pathlib import Path # Directory where this script is located (for finding template.html) SCRIPT_DIR = Path(__file__).parent def load_template(template_file: str) -> str: """Load the HTML template from the specified file.""" template_path = SCRIPT_DIR / template_file return template_path.read_text(encoding="utf-8") def load_asset_as_base64(path: Path, mime_type: str) -> str: """Load a file and return it as a base64 data URL.""" if path.exists(): with open(path, "rb") as f: data = base64.b64encode(f.read()).decode("utf-8") return f"data:{mime_type};base64,{data}" return "" def get_all_games(db_path: str) -> tuple[list[dict], int]: """Query the database and return all games with playtime and categories, plus total library count.""" conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM games") total_library = cursor.fetchone()[0] cursor.execute(""" SELECT id, name, playtime, COALESCE(service, 'local') as service FROM games WHERE playtime > 0 ORDER BY playtime DESC """) games_rows = cursor.fetchall() cursor.execute(""" SELECT gc.game_id, c.name FROM games_categories gc JOIN categories c ON gc.category_id = c.id """) categories_rows = cursor.fetchall() conn.close() game_categories = {} for game_id, category in categories_rows: if game_id not in game_categories: game_categories[game_id] = [] game_categories[game_id].append(category) games = [ { "name": row[1], "playtime": row[2], "service": row[3], "categories": game_categories.get(row[0], []) } for row in games_rows ] return games, total_library def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str, template_file: str, bg_image_path: str = None) -> None: """Generate the HTML report.""" all_games, total_library = get_all_games(db_path) if not all_games: print("No games with playtime found in the database.") return total_playtime = sum(g["playtime"] for g in all_games) total_games = len(all_games) assets_path = Path(assets_dir) # Load background image (custom or default stripes) if bg_image_path and Path(bg_image_path).exists(): background_image = load_asset_as_base64(Path(bg_image_path), "image/png") background_image_custom = f"url('{background_image}')" else: background_image = load_asset_as_base64(assets_path / "Others" / "stripes.png", "image/png") background_image_custom = "none" # For templates that prefer no default background # Load fonts font_charcoal = load_asset_as_base64(assets_path / "Charcoal.ttf", "font/truetype") font_monaco = load_asset_as_base64(assets_path / "MONACO.TTF", "font/truetype") # Load images titlebar_bg = load_asset_as_base64(assets_path / "Windows" / "title-1-active.png", "image/png") title_stripes = load_asset_as_base64(assets_path / "Windows" / "title-1-active.png", "image/png") close_btn = load_asset_as_base64(assets_path / "Windows" / "close-active.png", "image/png") hide_btn = load_asset_as_base64(assets_path / "Windows" / "maximize-active.png", "image/png") shade_btn = load_asset_as_base64(assets_path / "Windows" / "shade-active.png", "image/png") check_off = load_asset_as_base64(assets_path / "Check-Radio" / "check-normal.png", "image/png") check_on = load_asset_as_base64(assets_path / "Check-Radio" / "check-active.png", "image/png") # Load scrollbar images scrollbar_trough_v = load_asset_as_base64(assets_path / "Scrollbars" / "trough-scrollbar-vert.png", "image/png") scrollbar_thumb_v = load_asset_as_base64(assets_path / "Scrollbars" / "slider-vertical.png", "image/png") scrollbar_up = load_asset_as_base64(assets_path / "Scrollbars" / "stepper-up.png", "image/png") scrollbar_down = load_asset_as_base64(assets_path / "Scrollbars" / "stepper-down.png", "image/png") # Load tab images tab_active = load_asset_as_base64(assets_path / "Tabs" / "tab-top-active.png", "image/png") tab_inactive = load_asset_as_base64(assets_path / "Tabs" / "tab-top.png", "image/png") html = load_template(template_file) html = html.replace("__ALL_GAMES__", json.dumps(all_games)) html = html.replace("__TOP_N__", str(top_n)) html = html.replace("__TOTAL_LIBRARY__", str(total_library)) html = html.replace("__FONT_CHARCOAL__", font_charcoal) html = html.replace("__FONT_MONACO__", font_monaco) html = html.replace("__BACKGROUND_IMAGE__", background_image) html = html.replace("__BACKGROUND_IMAGE_CUSTOM__", background_image_custom) html = html.replace("__TITLEBAR_BG__", titlebar_bg) html = html.replace("__TITLE_STRIPES__", title_stripes) html = html.replace("__CLOSE_BTN__", close_btn) html = html.replace("__HIDE_BTN__", hide_btn) html = html.replace("__SHADE_BTN__", shade_btn) html = html.replace("__CHECK_OFF__", check_off) html = html.replace("__CHECK_ON__", check_on) html = html.replace("__SCROLLBAR_TROUGH_V__", scrollbar_trough_v) html = html.replace("__SCROLLBAR_THUMB_V__", scrollbar_thumb_v) html = html.replace("__SCROLLBAR_UP__", scrollbar_up) html = html.replace("__SCROLLBAR_DOWN__", scrollbar_down) html = html.replace("__TAB_ACTIVE__", tab_active) html = html.replace("__TAB_INACTIVE__", tab_inactive) Path(output_path).write_text(html, encoding="utf-8") print(f"Report generated: {output_path}") print(f"Total games in library: {total_library}") print(f"Total games with playtime: {total_games}") print(f"Total playtime: {total_playtime:.1f} hours") def main(): parser = argparse.ArgumentParser( description="Generate an HTML playtime report from a Lutris database." ) parser.add_argument( "--db", default="pga.db", help="Path to the Lutris SQLite database (default: pga.db)" ) parser.add_argument( "--output", default="report.html", help="Output HTML file path (default: report.html)" ) parser.add_argument( "--top", type=int, default=10, help="Number of top games to show individually (default: 10)" ) parser.add_argument( "--assets", default="templates/Platinum", help="Path to Platinum assets directory (default: templates/Platinum)" ) parser.add_argument( "--background", default=None, help="Path to background image for tiling (default: Platinum stripes pattern)" ) parser.add_argument( "--template", default="templates/platinum.html", help="HTML template file to use (default: templates/platinum.html)" ) args = parser.parse_args() if not Path(args.db).exists(): print(f"Error: Database file not found: {args.db}") return 1 if not Path(args.assets).exists(): print(f"Error: Assets directory not found: {args.assets}") return 1 template_path = SCRIPT_DIR / args.template if not template_path.exists(): print(f"Error: Template file not found: {template_path}") return 1 generate_report(args.db, args.output, args.top, args.assets, args.template, args.background) return 0 if __name__ == "__main__": exit(main())