2026-02-09 13:12:17 -04:00
|
|
|
#!/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
|
|
|
|
|
|
2026-02-26 01:37:42 -04:00
|
|
|
# Directory where this script is located (for finding template.html)
|
|
|
|
|
SCRIPT_DIR = Path(__file__).parent
|
2026-02-09 13:12:17 -04:00
|
|
|
|
|
|
|
|
|
2026-02-26 01:37:42 -04:00
|
|
|
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")
|
2026-02-09 13:12:17 -04:00
|
|
|
|
|
|
|
|
|
2026-02-09 18:10:37 -04:00
|
|
|
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 ""
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 16:57:14 -04:00
|
|
|
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."""
|
2026-02-09 13:12:17 -04:00
|
|
|
conn = sqlite3.connect(db_path)
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
|
2026-02-09 16:57:14 -04:00
|
|
|
cursor.execute("SELECT COUNT(*) FROM games")
|
|
|
|
|
total_library = cursor.fetchone()[0]
|
|
|
|
|
|
2026-02-09 13:12:17 -04:00
|
|
|
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)
|
|
|
|
|
|
2026-02-09 16:57:14 -04:00
|
|
|
games = [
|
2026-02-09 13:12:17 -04:00
|
|
|
{
|
|
|
|
|
"name": row[1],
|
|
|
|
|
"playtime": row[2],
|
|
|
|
|
"service": row[3],
|
|
|
|
|
"categories": game_categories.get(row[0], [])
|
|
|
|
|
}
|
|
|
|
|
for row in games_rows
|
|
|
|
|
]
|
2026-02-09 16:57:14 -04:00
|
|
|
return games, total_library
|
2026-02-09 13:12:17 -04:00
|
|
|
|
|
|
|
|
|
2026-02-26 01:37:42 -04:00
|
|
|
def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str, template_file: str, bg_image_path: str = None) -> None:
|
2026-02-09 13:12:17 -04:00
|
|
|
"""Generate the HTML report."""
|
2026-02-09 16:57:14 -04:00
|
|
|
all_games, total_library = get_all_games(db_path)
|
2026-02-09 13:12:17 -04:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-02-09 18:10:37 -04:00
|
|
|
assets_path = Path(assets_dir)
|
|
|
|
|
|
|
|
|
|
# Load background image (custom or default stripes)
|
2026-02-09 13:12:17 -04:00
|
|
|
if bg_image_path and Path(bg_image_path).exists():
|
2026-02-09 18:10:37 -04:00
|
|
|
background_image = load_asset_as_base64(Path(bg_image_path), "image/png")
|
2026-02-26 03:18:13 -04:00
|
|
|
background_image_custom = f"url('{background_image}')"
|
2026-02-09 18:10:37 -04:00
|
|
|
else:
|
|
|
|
|
background_image = load_asset_as_base64(assets_path / "Others" / "stripes.png", "image/png")
|
2026-02-26 03:18:13 -04:00
|
|
|
background_image_custom = "none" # For templates that prefer no default background
|
2026-02-09 18:10:37 -04:00
|
|
|
|
|
|
|
|
# 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")
|
2026-02-10 22:40:24 -04:00
|
|
|
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")
|
2026-02-09 18:10:37 -04:00
|
|
|
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")
|
2026-02-09 13:12:17 -04:00
|
|
|
|
2026-02-26 01:37:42 -04:00
|
|
|
html = load_template(template_file)
|
|
|
|
|
html = html.replace("__ALL_GAMES__", json.dumps(all_games))
|
2026-02-09 13:12:17 -04:00
|
|
|
html = html.replace("__TOP_N__", str(top_n))
|
2026-02-09 16:57:14 -04:00
|
|
|
html = html.replace("__TOTAL_LIBRARY__", str(total_library))
|
2026-02-09 18:10:37 -04:00
|
|
|
html = html.replace("__FONT_CHARCOAL__", font_charcoal)
|
|
|
|
|
html = html.replace("__FONT_MONACO__", font_monaco)
|
|
|
|
|
html = html.replace("__BACKGROUND_IMAGE__", background_image)
|
2026-02-26 03:18:13 -04:00
|
|
|
html = html.replace("__BACKGROUND_IMAGE_CUSTOM__", background_image_custom)
|
2026-02-09 18:10:37 -04:00
|
|
|
html = html.replace("__TITLEBAR_BG__", titlebar_bg)
|
|
|
|
|
html = html.replace("__TITLE_STRIPES__", title_stripes)
|
|
|
|
|
html = html.replace("__CLOSE_BTN__", close_btn)
|
2026-02-10 22:40:24 -04:00
|
|
|
html = html.replace("__HIDE_BTN__", hide_btn)
|
|
|
|
|
html = html.replace("__SHADE_BTN__", shade_btn)
|
2026-02-09 18:10:37 -04:00
|
|
|
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)
|
2026-02-09 13:12:17 -04:00
|
|
|
|
|
|
|
|
Path(output_path).write_text(html, encoding="utf-8")
|
|
|
|
|
print(f"Report generated: {output_path}")
|
2026-02-09 16:57:14 -04:00
|
|
|
print(f"Total games in library: {total_library}")
|
2026-02-09 13:12:17 -04:00
|
|
|
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)"
|
|
|
|
|
)
|
2026-02-09 18:10:37 -04:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--assets",
|
2026-02-26 03:21:25 -04:00
|
|
|
default="templates/Platinum",
|
|
|
|
|
help="Path to Platinum assets directory (default: templates/Platinum)"
|
2026-02-09 18:10:37 -04:00
|
|
|
)
|
2026-02-09 13:12:17 -04:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--background",
|
2026-02-09 18:10:37 -04:00
|
|
|
default=None,
|
|
|
|
|
help="Path to background image for tiling (default: Platinum stripes pattern)"
|
2026-02-09 13:12:17 -04:00
|
|
|
)
|
2026-02-26 01:37:42 -04:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--template",
|
2026-02-26 03:21:25 -04:00
|
|
|
default="templates/platinum.html",
|
|
|
|
|
help="HTML template file to use (default: templates/platinum.html)"
|
2026-02-26 01:37:42 -04:00
|
|
|
)
|
2026-02-09 13:12:17 -04:00
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
if not Path(args.db).exists():
|
|
|
|
|
print(f"Error: Database file not found: {args.db}")
|
|
|
|
|
return 1
|
|
|
|
|
|
2026-02-09 18:10:37 -04:00
|
|
|
if not Path(args.assets).exists():
|
|
|
|
|
print(f"Error: Assets directory not found: {args.assets}")
|
|
|
|
|
return 1
|
|
|
|
|
|
2026-02-26 01:37:42 -04:00
|
|
|
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)
|
2026-02-09 13:12:17 -04:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
exit(main())
|