Files
Lutris-Playtime-Report-Gene…/generate_report.py
Miguel Astor f9d28b73f8 Added code.
2026-02-09 13:12:17 -04:00

674 lines
23 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-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) -> list[dict]:
"""Query the database and return all games with playtime and categories."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Get games with playtime
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)
return [
{
"name": row[1],
"playtime": row[2],
"service": row[3],
"categories": game_categories.get(row[0], [])
}
for row in games_rows
]
def generate_report(db_path: str, output_path: str, top_n: int, bg_image_path: str = None) -> None:
"""Generate the HTML report."""
all_games = 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("__BACKGROUND_IMAGE__", bg_data_url)
Path(output_path).write_text(html, encoding="utf-8")
print(f"Report generated: {output_path}")
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())