Personal game library — Flask + SQLite + Steam API
[← Back to Portfolio]A personal web app for tracking my game library, playtime, achievements, and session history. It runs as a separate Flask service on its own subdomain and stores everything in SQLite. Steam data syncs automatically — a background thread polls the Steam API every 30 minutes to keep hours current, and a scheduled job at 00:05 EST records a full snapshot each night to power the activity feed.
The database has five core tables: games, achievements,
tags, daily_snapshots, and daily_game_snapshots.
The snapshot tables are what make the daily feed work — by diffing consecutive snapshots
you can reconstruct exactly which games were played on any given day and for how long.
There's also a steam_import_status table that makes bulk imports resumable:
if the import is interrupted mid-way, it picks up from where it left off rather than
starting over.
games stores the library;
achievements links to it with a CASCADE delete. The two snapshot tables
record nightly totals and per-game breakdowns separately so the daily diff query stays
simple. steam_import_status tracks which app IDs have been imported
so bulk imports are idempotent and resumable.CREATE TABLE games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
platform TEXT,
status TEXT, -- 'Playing', 'Completed', 'Backlog', etc.
rating INTEGER,
hours_played REAL,
steam_app_id INTEGER,
cover_url TEXT,
completion_date TEXT,
is_favorite INTEGER DEFAULT 0
);
CREATE TABLE achievements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id INTEGER,
title TEXT NOT NULL,
description TEXT,
date TEXT,
unlocked INTEGER DEFAULT 1,
icon_url TEXT,
FOREIGN KEY(game_id) REFERENCES games(id) ON DELETE CASCADE
);
CREATE TABLE daily_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL UNIQUE,
total_hours REAL NOT NULL,
games_played INTEGER NOT NULL
);
CREATE TABLE daily_game_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
game_id INTEGER NOT NULL,
game_title TEXT NOT NULL,
hours_played REAL NOT NULL,
UNIQUE(date, game_id),
FOREIGN KEY(game_id) REFERENCES games(id) ON DELETE CASCADE
);
CREATE TABLE steam_import_status (
steam_app_id INTEGER UNIQUE,
game_imported INTEGER DEFAULT 0,
achievements_imported INTEGER DEFAULT 0,
error_message TEXT
);
# GET /api/games cur.execute(""" SELECT g.*, COUNT(CASE WHEN a.unlocked=1 THEN 1 END) AS unlocked_achievements, COUNT(a.id) AS total_achievements, CASE WHEN COUNT(a.id) > 0 THEN ROUND(COUNT(CASE WHEN a.unlocked=1 THEN 1 END) * 100.0 / COUNT(a.id), 1) ELSE 0 END AS completion_percentage FROM games g LEFT JOIN achievements a ON g.id = a.game_id GROUP BY g.id ORDER BY g.is_favorite DESC, g.created_at DESC """)
steam_import_status, and when fetching achievements commits
per-game rather than in one big transaction — that way a timeout or API error mid-import
doesn't lose all the work done so far. The mandatory 1-second sleep between achievement
API calls is Steam's rate limit.# fetch owned games, sorted most-played first steam_games.sort(key=lambda x: x.get('playtime_forever', 0), reverse=True) # check what's already been imported cur.execute('SELECT steam_app_id, game_imported, achievements_imported FROM steam_import_status') import_status = {row['steam_app_id']: row for row in cur.fetchall()} for i, game in enumerate(steam_games): app_id = game['appid'] # skip if fully imported status = import_status.get(app_id) if status and status['game_imported'] == 1: continue hours = round(game.get('playtime_forever', 0) / 60, 1) cover = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/header.jpg" cur.execute( "INSERT INTO games (title, platform, status, hours_played, steam_app_id, cover_url)" " VALUES (?,?,?,?,?,?)", (game['name'], 'PC', 'Playing', hours, app_id, cover) ) game_id = cur.lastrowid cur.execute('INSERT OR REPLACE INTO steam_import_status (steam_app_id, game_imported) VALUES (?,1)', (app_id,)) if import_achievements: achievements = get_steam_achievements(app_id) for ach in achievements: cur.execute( 'INSERT INTO achievements (game_id, title, description, date, unlocked, icon_url)' ' VALUES (?,?,?,?,?,?)', (game_id, ach['name'], ach['description'], ach['unlock_date'], ach['achieved'], ach['icon']) ) cur.execute('UPDATE steam_import_status SET achievements_imported=1 WHERE steam_app_id=?', (app_id,)) conn.commit() # commit per-game so a mid-import failure loses minimal work if i < len(steam_games) - 1: time.sleep(1) # Steam rate limit: 1 req/sec for achievement endpoint
steam_achievements_list = get_steam_achievements(app_id) unlocked = sum(1 for a in steam_achievements_list if a.get('achieved', 0)) total = len(steam_achievements_list) if total > 0 and unlocked == total: # derive completion date from the latest achievement unlock dates = [a['unlock_date'] for a in steam_achievements_list if a['unlock_date']] try: completion_date = max( datetime.strptime(d, '%Y-%m-%d') for d in dates ).strftime('%Y-%m-%d') except (ValueError, TypeError): completion_date = datetime.now().strftime('%Y-%m-%d') cur.execute( 'UPDATE games SET status=?, completion_date=? WHERE id=?', ('Completed', completion_date, game_id) )
def record_daily_snapshot(self): date_str = self.get_current_date_est().isoformat() conn = sqlite3.connect(self.db_path) cur = conn.cursor() cur.execute('SELECT SUM(hours_played) FROM games WHERE hours_played IS NOT NULL') total_hours = cur.fetchone()[0] or 0 cur.execute('SELECT COUNT(*) FROM games WHERE hours_played > 0') games_count = cur.fetchone()[0] # upsert daily summary cur.execute('SELECT id FROM daily_snapshots WHERE date=?', (date_str,)) if cur.fetchone(): cur.execute('UPDATE daily_snapshots SET total_hours=?, games_played=? WHERE date=?', (total_hours, games_count, date_str)) cur.execute('DELETE FROM daily_game_snapshots WHERE date=?', (date_str,)) else: cur.execute('INSERT INTO daily_snapshots (date, total_hours, games_played) VALUES (?,?,?)', (date_str, total_hours, games_count)) # per-game breakdown for diff queries for game in cur.execute( 'SELECT id, title, hours_played, cover_url FROM games WHERE hours_played > 0' ): cur.execute( 'INSERT INTO daily_game_snapshots (date, game_id, game_title, hours_played, cover_url)' ' VALUES (?,?,?,?,?)', (date_str, game['id'], game['title'], game['hours_played'], game['cover_url']) ) conn.commit() def get_games_played_on_date(self, date_str): 'Diff today vs yesterday to find which games had hours added.' cur.execute('SELECT game_id, hours_played FROM daily_game_snapshots WHERE date=?', (date_str,)) today = {r['game_id']: r['hours_played'] for r in cur.fetchall()} prev_date = (datetime.strptime(date_str, '%Y-%m-%d').date() - timedelta(days=1)).isoformat() cur.execute('SELECT game_id, hours_played FROM daily_game_snapshots WHERE date=?', (prev_date,)) yesterday = {r['game_id']: r['hours_played'] for r in cur.fetchall()} result = [] for game_id, hours in today.items(): delta = hours - yesterday.get(game_id, 0) if delta > 0.01: # ignore sub-minute rounding noise result.append({'game_id': game_id, 'hours_added': round(delta, 1)}) result.sort(key=lambda x: x['hours_added'], reverse=True) return result
daily_game_snapshots comparing each date's row to the previous day's —
the same diff logic as get_games_played_on_date but done in SQL for the whole
30-day window at once. The final Python pass deduplicates (live games suppress their
same-day snapshot entry), merges achievement groups, and sorts live items to the top.# Live sessions: current hours vs. latest snapshot, no cron needed cur.execute(''' SELECT g.id as game_id, g.title as game_title, g.cover_url, g.hours_played as current_hours, COALESCE(dgs.hours_played, 0) as snapshot_hours, g.hours_played - COALESCE(dgs.hours_played, 0) as hours_added FROM games g LEFT JOIN ( SELECT game_id, hours_played FROM daily_game_snapshots WHERE date = (SELECT MAX(date) FROM daily_game_snapshots) ) dgs ON g.id = dgs.game_id WHERE g.hours_played > COALESCE(dgs.hours_played, 0) + 0.09 ORDER BY hours_added DESC ''') # Historical sessions: self-join on dgs, diff each day vs. the previous day cur.execute(''' SELECT dgs1.date, dgs1.game_id, dgs1.game_title, dgs1.cover_url, dgs1.hours_played as total_hours, dgs1.hours_played - COALESCE(dgs2.hours_played, 0) as hours_added FROM daily_game_snapshots dgs1 LEFT JOIN daily_game_snapshots dgs2 ON dgs1.game_id = dgs2.game_id AND dgs2.date = date(dgs1.date, '-1 day') WHERE dgs1.date >= date('now', '-30 days') AND (dgs1.hours_played - COALESCE(dgs2.hours_played, 0)) > 0.09 ORDER BY dgs1.date DESC, hours_added DESC LIMIT 100 ''') # Recent achievement unlocks cur.execute(''' SELECT a.title as ach_title, a.date, a.icon_url, g.id as game_id, g.title as game_title, g.cover_url FROM achievements a JOIN games g ON a.game_id = g.id WHERE a.unlocked = 1 AND a.date >= date('now', '-30 days') ORDER BY a.date DESC ''') # Python: group achievements, deduplicate live vs. historical, sort live_game_ids = {s['game_id'] for s in live_sessions} feed = [] for s in hist_sessions: # suppress today's snapshot entry for games already shown as live if s['date'] == today and s['game_id'] in live_game_ids: continue feed.append({'type': 'session', 'live': False, **s}) # sort: live items first, then newest date feed.sort(key=lambda x: (not x.get('live'), x['date']), reverse=True)
def setup_daily_scheduler(tracker): def job(): update_all_steam_hours_sync() # sync Steam before snapshotting sync_new_steam_games() # import any games added since last run tracker.record_daily_snapshot() schedule.every().day.at('05:05').do(job) # 00:05 EST = 05:05 UTC def run_scheduler(): while True: schedule.run_pending() time.sleep(60) threading.Thread(target=run_scheduler, daemon=True).start() def setup_steam_poller(interval_minutes=30): def poll(): time.sleep(60) # startup delay — don't hit Steam during boot while True: try: update_all_steam_hours_sync() except Exception as e: logger.error(f"Steam poll error: {e}") time.sleep(interval_minutes * 60) threading.Thread(target=poll, daemon=True, name='steam-poller').start()