Files
Lutris-Playtime-Report-Gene…/generate_report.py
Miguel Astor 31e8d152ae Add By Runner tab to summaries section in all templates
Extract runner field from Lutris database and display playtime
grouped by runner (wine, linux, steam, dosbox, etc.) in a new
third tab alongside Top Games and By Category.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-03 03:09:29 -04:00

216 lines
8.8 KiB
Python

#!/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, COALESCE(runner, 'unknown') as runner
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],
"runner": row[4],
"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())