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>
685 lines
24 KiB
Python
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())
|