Skip to content

Commit 1a6f0f7

Browse files
MRP-2 T13: Low-priority micro-fixes — 8 DRY deduplication patterns
13.1: _get_apps_without_join(table) replaces 4 identical LEFT JOIN queries 13.2: _get_stale_count(table, days) replaces 2 identical stale-count queries 13.3: dataclasses.replace() replaces manual Profile reconstruction (2x) 13.5: _load_json_directory() unifies shared/locale file loading in i18n.py 13.7: _open_readonly_db() in BaseExternalParser for Lutris/itch parsers 13.9: _wire_and_start_track() deduplicates 4x track signal wiring 13.10: _hydrate_row() unifies SmartCollection deserialization (2x) 13.11: _post_search() deduplicates HLTB retry POST request Skipped 13.4 (mkdir one-liner), 13.6 (minimal ROI), 13.8 (already done in T09).
1 parent 16ac26c commit 1a6f0f7

9 files changed

Lines changed: 162 additions & 228 deletions

File tree

src/core/db/enrichment_queries.py

Lines changed: 47 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,41 @@ class EnrichmentQueryMixin:
2525
Requires ConnectionBase attributes: conn.
2626
"""
2727

28+
# ── Shared query helpers ─────────────────────────────────────────────
29+
30+
def _get_apps_without_join(self, table: str) -> list[tuple[int, str]]:
31+
"""Returns game-type apps with no entry in the given table.
32+
33+
Args:
34+
table: Target table name to LEFT JOIN against.
35+
36+
Returns:
37+
List of (app_id, name) tuples.
38+
"""
39+
cursor = self.conn.execute(
40+
f"SELECT g.app_id, g.name FROM games g"
41+
f" LEFT JOIN {table} t ON g.app_id = t.app_id"
42+
f" WHERE t.app_id IS NULL AND g.app_type IN ('game', '')"
43+
)
44+
return [(row[0], row[1]) for row in cursor.fetchall()]
45+
46+
def _get_stale_count(self, table: str, max_age_days: int) -> int:
47+
"""Counts entries with cache older than max_age_days.
48+
49+
Args:
50+
table: Table name with a last_updated column.
51+
max_age_days: Maximum cache age in days.
52+
53+
Returns:
54+
Number of stale entries.
55+
"""
56+
cutoff = int(time.time()) - (max_age_days * 86400)
57+
cursor = self.conn.execute(
58+
f"SELECT COUNT(*) FROM {table} WHERE last_updated < ?",
59+
(cutoff,),
60+
)
61+
return cursor.fetchone()[0]
62+
2863
# ── Metadata enrichment ──────────────────────────────────────────────
2964

3065
def upsert_game_metadata(self, app_id: int, **fields: Any) -> None:
@@ -130,17 +165,8 @@ def get_apps_missing_metadata(self) -> list[tuple[int, str]]:
130165
return [(row[0], row[1]) for row in cursor.fetchall()]
131166

132167
def get_apps_without_hltb(self) -> list[tuple[int, str]]:
133-
"""Returns game-type apps that have no HLTB data.
134-
135-
Returns:
136-
List of (app_id, name) tuples.
137-
"""
138-
cursor = self.conn.execute("""
139-
SELECT g.app_id, g.name FROM games g
140-
LEFT JOIN hltb_data h ON g.app_id = h.app_id
141-
WHERE h.app_id IS NULL AND g.app_type IN ('game', '')
142-
""")
143-
return [(row[0], row[1]) for row in cursor.fetchall()]
168+
"""Returns game-type apps that have no HLTB data."""
169+
return self._get_apps_without_join("hltb_data")
144170

145171
# ── HLTB ID cache ────────────────────────────────────────────────────
146172

@@ -254,17 +280,8 @@ def upsert_protondb(
254280
)
255281

256282
def get_apps_without_protondb(self) -> list[tuple[int, str]]:
257-
"""Returns game-type apps that have no ProtonDB rating.
258-
259-
Returns:
260-
List of (app_id, name) tuples.
261-
"""
262-
cursor = self.conn.execute("""
263-
SELECT g.app_id, g.name FROM games g
264-
LEFT JOIN protondb_ratings p ON g.app_id = p.app_id
265-
WHERE p.app_id IS NULL AND g.app_type IN ('game', '')
266-
""")
267-
return [(row[0], row[1]) for row in cursor.fetchall()]
283+
"""Returns game-type apps that have no ProtonDB rating."""
284+
return self._get_apps_without_join("protondb_ratings")
268285

269286
def get_apps_without_pegi(self) -> list[tuple[int, str]]:
270287
"""Returns game-type apps that have no PEGI age rating.
@@ -355,64 +372,22 @@ def upsert_achievements(self, app_id: int, achievements: list[dict]) -> None:
355372
)
356373

357374
def get_apps_without_achievements(self) -> list[tuple[int, str]]:
358-
"""Returns game-type apps that have no achievement_stats entry.
359-
360-
Returns:
361-
List of (app_id, name) tuples.
362-
"""
363-
cursor = self.conn.execute("""
364-
SELECT g.app_id, g.name FROM games g
365-
LEFT JOIN achievement_stats a ON g.app_id = a.app_id
366-
WHERE a.app_id IS NULL AND g.app_type IN ('game', '')
367-
""")
368-
return [(row[0], row[1]) for row in cursor.fetchall()]
375+
"""Returns game-type apps that have no achievement_stats entry."""
376+
return self._get_apps_without_join("achievement_stats")
369377

370378
# ── Health check queries ─────────────────────────────────────────────
371379

372380
def get_games_missing_artwork(self) -> list[tuple[int, str]]:
373-
"""Returns games that have no custom artwork entry.
374-
375-
Returns:
376-
List of (app_id, name) tuples.
377-
"""
378-
cursor = self.conn.execute("""
379-
SELECT g.app_id, g.name FROM games g
380-
LEFT JOIN custom_artwork ca ON g.app_id = ca.app_id
381-
WHERE ca.app_id IS NULL AND g.app_type IN ('game', '')
382-
""")
383-
return [(row[0], row[1]) for row in cursor.fetchall()]
381+
"""Returns games that have no custom artwork entry."""
382+
return self._get_apps_without_join("custom_artwork")
384383

385384
def get_stale_hltb_count(self, max_age_days: int = 30) -> int:
386-
"""Counts games with HLTB cache older than max_age_days.
387-
388-
Args:
389-
max_age_days: Maximum cache age in days.
390-
391-
Returns:
392-
Number of games with stale HLTB data.
393-
"""
394-
cutoff = int(time.time()) - (max_age_days * 86400)
395-
cursor = self.conn.execute(
396-
"SELECT COUNT(*) FROM hltb_data WHERE last_updated < ?",
397-
(cutoff,),
398-
)
399-
return cursor.fetchone()[0]
385+
"""Counts games with HLTB cache older than max_age_days."""
386+
return self._get_stale_count("hltb_data", max_age_days)
400387

401388
def get_stale_protondb_count(self, max_age_days: int = 7) -> int:
402-
"""Counts games with ProtonDB cache older than max_age_days.
403-
404-
Args:
405-
max_age_days: Maximum cache age in days.
406-
407-
Returns:
408-
Number of games with stale ProtonDB data.
409-
"""
410-
cutoff = int(time.time()) - (max_age_days * 86400)
411-
cursor = self.conn.execute(
412-
"SELECT COUNT(*) FROM protondb_ratings WHERE last_updated < ?",
413-
(cutoff,),
414-
)
415-
return cursor.fetchone()[0]
389+
"""Counts games with ProtonDB cache older than max_age_days."""
390+
return self._get_stale_count("protondb_ratings", max_age_days)
416391

417392
# ── Import recording ─────────────────────────────────────────────────
418393

src/core/profile_manager.py

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import re
1515
import shutil
1616
import time
17-
from dataclasses import dataclass
17+
from dataclasses import dataclass, replace
1818
from pathlib import Path
1919
from typing import Any
2020

@@ -273,20 +273,7 @@ def rename_profile(self, old_name: str, new_name: str) -> bool:
273273
except FileNotFoundError:
274274
return False
275275

276-
# Build renamed profile, preserving original created_at
277-
renamed = Profile(
278-
name=new_name.strip(),
279-
collections=old_profile.collections,
280-
autocat_methods=old_profile.autocat_methods,
281-
tags_per_game=old_profile.tags_per_game,
282-
ignore_common_tags=old_profile.ignore_common_tags,
283-
filter_enabled_types=old_profile.filter_enabled_types,
284-
filter_enabled_platforms=old_profile.filter_enabled_platforms,
285-
filter_active_statuses=old_profile.filter_active_statuses,
286-
filter_active_languages=old_profile.filter_active_languages,
287-
sort_key=old_profile.sort_key,
288-
created_at=old_profile.created_at,
289-
)
276+
renamed = replace(old_profile, name=new_name.strip())
290277

291278
self.save_profile(renamed)
292279
# Only delete old if filename actually changed
@@ -343,19 +330,7 @@ def import_profile(self, source_path: Path) -> Profile:
343330

344331
# Stamp import time if no creation time was set
345332
if profile.created_at == 0.0:
346-
profile = Profile(
347-
name=profile.name,
348-
collections=profile.collections,
349-
autocat_methods=profile.autocat_methods,
350-
tags_per_game=profile.tags_per_game,
351-
ignore_common_tags=profile.ignore_common_tags,
352-
filter_enabled_types=profile.filter_enabled_types,
353-
filter_enabled_platforms=profile.filter_enabled_platforms,
354-
filter_active_statuses=profile.filter_active_statuses,
355-
filter_active_languages=profile.filter_active_languages,
356-
sort_key=profile.sort_key,
357-
created_at=time.time(),
358-
)
333+
profile = replace(profile, created_at=time.time())
359334

360335
self.save_profile(profile)
361336
logger.info("Imported profile '%s' from %s", profile.name, source_path)

src/integrations/external_games/base_parser.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
import logging
10+
import sqlite3
1011
from abc import ABC, abstractmethod
1112
from pathlib import Path
1213

@@ -70,3 +71,20 @@ def _find_config_file(self) -> Path | None:
7071
logger.debug("Found %s config: %s", self.platform_name(), path)
7172
return path
7273
return None
74+
75+
def _open_readonly_db(self, db_path: Path) -> sqlite3.Connection | None:
76+
"""Opens a SQLite database in read-only mode with Row factory.
77+
78+
Args:
79+
db_path: Path to the SQLite database file.
80+
81+
Returns:
82+
Connection with Row factory set, or None on error.
83+
"""
84+
try:
85+
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
86+
conn.row_factory = sqlite3.Row
87+
return conn
88+
except sqlite3.Error as e:
89+
logger.warning("Failed to open %s database: %s", self.platform_name(), e)
90+
return None

src/integrations/external_games/itch_parser.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,8 @@ def read_games(self) -> list[ExternalGame]:
9191
if not db_path.exists():
9292
return []
9393

94-
try:
95-
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
96-
conn.row_factory = sqlite3.Row
97-
except sqlite3.Error as e:
98-
logger.warning("Failed to open itch.io database: %s", e)
94+
conn = self._open_readonly_db(db_path)
95+
if not conn:
9996
return []
10097

10198
games: list[ExternalGame] = []

src/integrations/external_games/lutris_parser.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,8 @@ def read_games(self) -> list[ExternalGame]:
8484
if not db_path:
8585
return []
8686

87-
try:
88-
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
89-
conn.row_factory = sqlite3.Row
90-
except sqlite3.Error as e:
91-
logger.warning("Failed to open Lutris database: %s", e)
87+
conn = self._open_readonly_db(db_path)
88+
if not conn:
9289
return []
9390

9491
games: list[ExternalGame] = []

src/integrations/hltb_api.py

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,27 @@ def search_game(self, name: str, app_id: int = 0) -> HLTBResult | None:
267267
return to_result(match)
268268
return None
269269

270+
def _post_search(self, payload: dict) -> requests.Response:
271+
"""Sends a search POST request to the HLTB API.
272+
273+
Args:
274+
payload: JSON payload for the search.
275+
276+
Returns:
277+
Response object from the API.
278+
"""
279+
return self._session.post(
280+
f"{_HLTB_BASE}/api/{self._api_path}",
281+
json=payload,
282+
headers={
283+
"Content-Type": "application/json",
284+
"Origin": _HLTB_BASE,
285+
"Referer": f"{_HLTB_BASE}/",
286+
"x-auth-token": self._auth_token,
287+
},
288+
timeout=15,
289+
)
290+
270291
def _search_and_find(self, search_name: str) -> tuple[dict | None, int]:
271292
"""Performs an HLTB API search and returns the best match with distance.
272293
@@ -306,38 +327,15 @@ def _search_and_find(self, search_name: str) -> tuple[dict | None, int]:
306327
"useCache": True,
307328
}
308329

309-
search_url = f"{_HLTB_BASE}/api/{self._api_path}"
310-
311330
try:
312-
resp = self._session.post(
313-
search_url,
314-
json=payload,
315-
headers={
316-
"Content-Type": "application/json",
317-
"Origin": _HLTB_BASE,
318-
"Referer": f"{_HLTB_BASE}/",
319-
"x-auth-token": self._auth_token,
320-
},
321-
timeout=15,
322-
)
331+
resp = self._post_search(payload)
323332
# If 404 or 403, invalidate cache and retry once
324333
if resp.status_code in (403, 404):
325334
logger.info("HLTB endpoint returned %d, refreshing...", resp.status_code)
326335
self._cache_time = 0.0
327336
if not self._ensure_api_ready():
328337
return None, 0
329-
search_url = f"{_HLTB_BASE}/api/{self._api_path}"
330-
resp = self._session.post(
331-
search_url,
332-
json=payload,
333-
headers={
334-
"Content-Type": "application/json",
335-
"Origin": _HLTB_BASE,
336-
"Referer": f"{_HLTB_BASE}/",
337-
"x-auth-token": self._auth_token,
338-
},
339-
timeout=15,
340-
)
338+
resp = self._post_search(payload)
341339

342340
resp.raise_for_status()
343341
data = resp.json()

0 commit comments

Comments
 (0)