Files
Lutris-Playtime-Report-Gene…/generate_report.py
Miguel Astor a3b88d9fe4 Add total library count to report stats
Show "Games in Library" stat alongside "Games Played" and "Total Playtime".
Games with zero or null playtime are excluded from tables and charts but
now counted in the library total.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 16:57:14 -04:00

685 lines
24 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
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lutris Playtime Report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--card-bg: rgba(33, 34, 44, 0.75);
--card-hover: rgba(40, 42, 54, 0.85);
--text-color: #eee;
--text-muted: #aaa;
--accent: #ff7f50;
--border-color: rgba(255, 255, 255, 0.1);
--badge-bg: rgba(255, 255, 255, 0.15);
--table-header: rgba(22, 33, 62, 0.8);
}
@media (prefers-color-scheme: light) {
:root {
--card-bg: rgba(255, 255, 255, 0.75);
--card-hover: rgba(245, 245, 245, 0.85);
--text-color: #111;
--text-muted: #555;
--border-color: rgba(0, 0, 0, 0.1);
--badge-bg: rgba(0, 0, 0, 0.1);
--table-header: rgba(240, 240, 240, 0.9);
}
}
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-image: url('__BACKGROUND_IMAGE__');
background-repeat: repeat;
min-height: 100vh;
color: var(--text-color);
}
h1 {
text-align: center;
color: var(--accent);
margin: 0;
}
.card {
background: var(--card-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid var(--border-color);
}
.filters {
display: flex;
justify-content: center;
gap: 15px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--badge-bg);
border-radius: 20px;
cursor: pointer;
transition: background 0.2s;
user-select: none;
}
.filter-label:hover {
background: var(--card-hover);
}
.filter-label input {
accent-color: var(--accent);
width: 16px;
height: 16px;
cursor: pointer;
}
.filter-label .service-name {
text-transform: capitalize;
}
.filter-label .service-count {
color: var(--text-muted);
font-size: 0.85em;
}
.stats {
display: flex;
justify-content: center;
gap: 40px;
flex-wrap: wrap;
}
.stat {
text-align: center;
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: var(--accent);
}
.stat-label {
font-size: 0.9em;
color: var(--text-muted);
}
.chart-container {
position: relative;
max-width: 600px;
margin: 0 auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background: var(--table-header);
color: var(--accent);
}
tr:hover {
background: var(--card-hover);
}
.time {
font-family: monospace;
}
.percent {
font-family: monospace;
text-align: right;
}
.color-box {
display: inline-block;
width: 16px;
height: 16px;
margin-right: 8px;
vertical-align: middle;
border-radius: 3px;
}
.service-badge {
display: inline-block;
font-size: 0.75em;
padding: 2px 8px;
border-radius: 10px;
background: var(--badge-bg);
color: var(--text-muted);
margin-left: 8px;
text-transform: capitalize;
}
.no-data {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
.others-row {
cursor: pointer;
}
.others-row:hover {
background: var(--card-hover);
}
.others-row td:first-child::before {
content: '';
display: inline-block;
width: 0;
height: 0;
border-left: 6px solid var(--accent);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
margin-right: 8px;
transition: transform 0.2s;
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: var(--badge-bg);
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 40px;
border-bottom: 1px solid var(--border-color);
}
.tables-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 900px) {
.tables-container {
grid-template-columns: 1fr;
}
.tables-container .card {
height: auto !important;
}
}
@media (min-width: 901px) {
.tables-container {
align-items: stretch;
}
.tables-container .card {
display: flex;
flex-direction: column;
}
.tables-container .card .table-wrapper {
flex: 1;
overflow-y: auto;
}
}
.table-section h2 {
color: var(--accent);
font-size: 1.2em;
margin: 0 0 15px 0;
}
</style>
</head>
<body>
<div class="card">
<h1>Lutris Playtime Report</h1>
</div>
<div class="card">
<div class="filters" id="filters"></div>
</div>
<div class="card">
<div class="stats">
<div class="stat">
<div class="stat-value" id="total-library">__TOTAL_LIBRARY__</div>
<div class="stat-label">Games in Library</div>
</div>
<div class="stat">
<div class="stat-value" id="total-games">0</div>
<div class="stat-label">Games Played</div>
</div>
<div class="stat">
<div class="stat-value" id="total-time">0h</div>
<div class="stat-label">Total Playtime</div>
</div>
</div>
</div>
<div class="card">
<div class="chart-container">
<canvas id="playtime-chart"></canvas>
</div>
</div>
<div class="tables-container">
<div class="card">
<h2 class="table-section">Top Games</h2>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Game</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="games-table"></tbody>
</table>
</div>
</div>
<div class="card">
<h2 class="table-section">By Category</h2>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Category</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="categories-table"></tbody>
</table>
</div>
</div>
</div>
<script>
const allGames = __ALL_GAMES__;
const topN = __TOP_N__;
// Colors for the chart
const colors = [
'#ff6384', '#36a2eb', '#ffce56', '#4bc0c0', '#9966ff',
'#ff9f40', '#ff6384', '#c9cbcf', '#7bc043', '#ee4035',
'#808080'
];
// Format hours to "Xh Ym" format
function formatTime(hours) {
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
if (h === 0) return m + 'm';
if (m === 0) return h + 'h';
return h + 'h ' + m + 'm';
}
// Get unique services and their counts
function getServices() {
const services = {};
allGames.forEach(g => {
const s = g.service;
if (!services[s]) services[s] = { count: 0, playtime: 0 };
services[s].count++;
services[s].playtime += g.playtime;
});
return Object.entries(services)
.sort((a, b) => b[1].playtime - a[1].playtime)
.map(([name, data]) => ({ name, ...data }));
}
// Build filter checkboxes
const services = getServices();
const filtersDiv = document.getElementById('filters');
services.forEach(service => {
const label = document.createElement('label');
label.className = 'filter-label';
label.innerHTML = `
<input type="checkbox" value="${service.name}" checked>
<span class="service-name">${service.name}</span>
<span class="service-count">(${service.count})</span>
`;
filtersDiv.appendChild(label);
});
// Chart instance
let chart = null;
const ctx = document.getElementById('playtime-chart').getContext('2d');
// Get selected services
function getSelectedServices() {
const checkboxes = filtersDiv.querySelectorAll('input[type="checkbox"]');
return Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
}
// Filter and aggregate data
function getFilteredData(selectedServices) {
const filtered = allGames
.filter(g => selectedServices.includes(g.service))
.sort((a, b) => b.playtime - a.playtime);
if (filtered.length === 0) {
return { chartData: [], othersGames: [], categoriesData: [], totalPlaytime: 0, totalGames: 0 };
}
const totalPlaytime = filtered.reduce((sum, g) => sum + g.playtime, 0);
const totalGames = filtered.length;
// Get top N games
const topGames = filtered.slice(0, topN).map(g => ({
name: g.name,
playtime: g.playtime,
service: g.service
}));
// Games in "Others" category
let othersGames = [];
// Add "Others" if needed
if (filtered.length > topN) {
othersGames = filtered.slice(topN).map(g => ({
name: g.name,
playtime: g.playtime,
service: g.service
}));
const othersPlaytime = othersGames.reduce((sum, g) => sum + g.playtime, 0);
const othersCount = othersGames.length;
topGames.push({
name: `Others (${othersCount} games)`,
playtime: othersPlaytime,
service: 'others'
});
}
// Aggregate by category
const categoryMap = {};
filtered.forEach(g => {
if (g.categories && g.categories.length > 0) {
g.categories.forEach(cat => {
if (cat === '.hidden' || cat === 'favorite' || cat === 'Horny') return;
if (!categoryMap[cat]) {
categoryMap[cat] = { name: cat, playtime: 0, gameCount: 0 };
}
categoryMap[cat].playtime += g.playtime;
categoryMap[cat].gameCount++;
});
}
});
const categoriesData = Object.values(categoryMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, totalPlaytime, totalGames };
}
// Update the display
function updateDisplay() {
const selectedServices = getSelectedServices();
const { chartData, othersGames, categoriesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
// Update stats
document.getElementById('total-games').textContent = totalGames;
document.getElementById('total-time').textContent = formatTime(totalPlaytime);
// Update chart
if (chart) {
chart.destroy();
}
if (chartData.length === 0) {
document.getElementById('games-table').innerHTML =
'<tr><td colspan="4" class="no-data">No games match the selected filters</td></tr>';
document.getElementById('categories-table').innerHTML =
'<tr><td colspan="4" class="no-data">No categories found</td></tr>';
return;
}
chart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: chartData.map(g => g.name),
datasets: [{
data: chartData.map(g => g.playtime),
backgroundColor: colors.slice(0, chartData.length),
borderColor: '#1a1a2e',
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: '#eee',
padding: 15
}
},
tooltip: {
callbacks: {
label: function(context) {
const value = context.raw;
const percent = ((value / totalPlaytime) * 100).toFixed(1);
return ' ' + formatTime(value) + ' (' + percent + '%)';
}
}
}
}
}
});
// Update table
const tbody = document.getElementById('games-table');
tbody.innerHTML = '';
chartData.forEach((game, index) => {
const percent = ((game.playtime / totalPlaytime) * 100).toFixed(1);
const isOthers = game.service === 'others';
const serviceBadge = !isOthers
? `<span class="service-badge">${game.service}</span>`
: '';
const row = document.createElement('tr');
if (isOthers) {
row.className = 'others-row';
}
row.innerHTML = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${colors[index]}"></span>
${game.name}${serviceBadge}
</td>
<td class="time">${formatTime(game.playtime)}</td>
<td class="percent">${percent}%</td>
`;
tbody.appendChild(row);
// Add expandable rows for "Others"
if (isOthers && othersGames.length > 0) {
const detailRows = [];
othersGames.forEach((otherGame, otherIndex) => {
const otherPercent = ((otherGame.playtime / totalPlaytime) * 100).toFixed(1);
const detailRow = document.createElement('tr');
detailRow.className = 'others-detail';
detailRow.innerHTML = `
<td>${index + 1}.${otherIndex + 1}</td>
<td>
${otherGame.name}
<span class="service-badge">${otherGame.service}</span>
</td>
<td class="time">${formatTime(otherGame.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
tbody.appendChild(detailRow);
detailRows.push(detailRow);
});
// Toggle expand/collapse on click
row.addEventListener('click', () => {
row.classList.toggle('expanded');
detailRows.forEach(dr => dr.classList.toggle('visible'));
});
}
});
// Update categories table
const catTbody = document.getElementById('categories-table');
catTbody.innerHTML = '';
if (categoriesData.length === 0) {
catTbody.innerHTML = '<tr><td colspan="4" class="no-data">No categories found</td></tr>';
} else {
categoriesData.forEach((cat, index) => {
const percent = ((cat.playtime / totalPlaytime) * 100).toFixed(1);
const row = document.createElement('tr');
row.innerHTML = `
<td>${index + 1}</td>
<td>${cat.name} <span class="service-badge">${cat.gameCount} games</span></td>
<td class="time">${formatTime(cat.playtime)}</td>
<td class="percent">${percent}%</td>
`;
catTbody.appendChild(row);
});
}
}
// Listen for filter changes
filtersDiv.addEventListener('change', updateDisplay);
// Initial render
updateDisplay();
</script>
</body>
</html>
"""
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()
# Get total games in library
cursor.execute("SELECT COUNT(*) FROM games")
total_library = cursor.fetchone()[0]
# Get games with playtime > 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()
# Get categories for each game
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()
# Build game_id -> categories mapping
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, 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)
# Load background image as base64
bg_data_url = ""
if bg_image_path and Path(bg_image_path).exists():
with open(bg_image_path, "rb") as f:
bg_base64 = base64.b64encode(f.read()).decode("utf-8")
bg_data_url = f"data:image/png;base64,{bg_base64}"
html = HTML_TEMPLATE.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("__BACKGROUND_IMAGE__", bg_data_url)
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(
"--background",
default="background.png",
help="Path to background image (default: background.png)"
)
args = parser.parse_args()
if not Path(args.db).exists():
print(f"Error: Database file not found: {args.db}")
return 1
generate_report(args.db, args.output, args.top, args.background)
return 0
if __name__ == "__main__":
exit(main())