Add version decision cache and library-to-status propagation
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [1.0.1] — 2026-05-12
|
||||
|
||||
### Improvements
|
||||
|
||||
- Version decision cache — ambiguous version comparisons are stored in `config.json`; the dialog is shown only once per version pair, answer is reused on subsequent checks
|
||||
- Library → Status propagation — after "Refresh Library", the Status tab is automatically populated for both platforms without additional API calls; product data fetched during library refresh is reused directly
|
||||
- Download logic refactored into `_run_download_for_platform()` shared helper
|
||||
|
||||
## [1.0.0] — 2026-04-09
|
||||
|
||||
### Features
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gogupdater"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"}
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
"""Auto-generated — do not edit manually."""
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
@@ -24,6 +24,7 @@ class AppConfig:
|
||||
self.english_only: bool = False
|
||||
self.include_bonus: bool = False
|
||||
self.game_settings: dict[str, GameSettings] = {}
|
||||
self.version_decisions: dict[str, bool] = {}
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
@@ -41,6 +42,7 @@ class AppConfig:
|
||||
gid: GameSettings.from_dict(gs)
|
||||
for gid, gs in data.get("game_settings", {}).items()
|
||||
}
|
||||
self.version_decisions = data.get("version_decisions", {})
|
||||
except (json.JSONDecodeError, OSError):
|
||||
logger.warning("Failed to read config.json, using defaults")
|
||||
|
||||
@@ -58,6 +60,7 @@ class AppConfig:
|
||||
for gid, gs in self.game_settings.items()
|
||||
if not gs.is_default()
|
||||
},
|
||||
"version_decisions": self.version_decisions,
|
||||
}
|
||||
self.config_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ class MainWindow(QMainWindow):
|
||||
def _connect_signals(self) -> None:
|
||||
self.auth_tab.login_state_changed.connect(self._on_login_changed)
|
||||
self.settings_tab.english_only_changed.connect(self._on_english_only_changed)
|
||||
self.library_tab.library_refreshed.connect(self.status_tab.check_updates_from_cache)
|
||||
|
||||
def _on_login_changed(self, logged_in: bool) -> None:
|
||||
logger.info(f"Login state changed: logged_in={logged_in}")
|
||||
|
||||
@@ -37,6 +37,7 @@ class LibraryTab(QWidget):
|
||||
"""Tab showing owned games with checkboxes. DLCs appear as children of their parent game."""
|
||||
|
||||
managed_games_changed = Signal()
|
||||
library_refreshed = Signal(object, object) # products_cache: dict[str, dict], owned_set: set[str]
|
||||
|
||||
def __init__(self, api: GogApi, config: AppConfig, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -99,6 +100,7 @@ class LibraryTab(QWidget):
|
||||
# games_data shared for all platforms — platform filtering happens in populate
|
||||
games_data: list[tuple[str, str, list[tuple[str, str]], set[str]]] = []
|
||||
# (game_id, title, [(dlc_id, dlc_title)], available_platforms)
|
||||
products_cache: dict[str, dict] = {}
|
||||
|
||||
for gid in owned_ids:
|
||||
product = self.api.get_product_info(gid)
|
||||
@@ -106,6 +108,7 @@ class LibraryTab(QWidget):
|
||||
games_data.append((str(gid), f"Game {gid}", [], set()))
|
||||
continue
|
||||
|
||||
products_cache[str(gid)] = product
|
||||
title = product.get("title", f"Game {gid}")
|
||||
dlcs: list[tuple[str, str]] = []
|
||||
for dlc in product.get("expanded_dlcs", []):
|
||||
@@ -159,6 +162,7 @@ class LibraryTab(QWidget):
|
||||
self.status_label.setText(
|
||||
f"Loaded {win_count} Windows / {lin_count} Linux games. Double-click to configure per-game settings."
|
||||
)
|
||||
self.library_refreshed.emit(products_cache, owned_set)
|
||||
|
||||
def _make_node(self, game_id: str, title: str, check: Qt.CheckState) -> QTreeWidgetItem:
|
||||
node = QTreeWidgetItem()
|
||||
|
||||
+94
-52
@@ -33,7 +33,7 @@ from src.models import (
|
||||
LanguageVersionInfo,
|
||||
language_folder_name,
|
||||
)
|
||||
from src.version_compare import CompareResult, compare_versions, format_comparison_question
|
||||
from src.version_compare import CompareResult, compare_versions, format_comparison_question, normalize_version
|
||||
|
||||
STATUS_COLORS: dict[GameStatus, str] = {
|
||||
GameStatus.UP_TO_DATE: "#2e7d32",
|
||||
@@ -90,6 +90,7 @@ class StatusTab(QWidget):
|
||||
self.download_btn.setEnabled(False)
|
||||
controls.addWidget(self.download_btn)
|
||||
|
||||
|
||||
self.prune_btn = QPushButton("Prune Old Versions")
|
||||
self.prune_btn.clicked.connect(self.prune_versions)
|
||||
controls.addWidget(self.prune_btn)
|
||||
@@ -153,15 +154,32 @@ class StatusTab(QWidget):
|
||||
|
||||
self.check_btn.setEnabled(False)
|
||||
self.status_label.setText("Checking updates...")
|
||||
for items in self.status_items.values():
|
||||
items.clear()
|
||||
self.tree_windows.clear()
|
||||
self.tree_linux.clear()
|
||||
self._update_platform_tab_visibility()
|
||||
|
||||
# TODO: Run in a thread to avoid blocking the GUI
|
||||
|
||||
# Verify metadata integrity — remove entries for files deleted outside the app
|
||||
self._verify_metadata()
|
||||
|
||||
owned_set = self.api.get_owned_ids_set()
|
||||
products_cache: dict[str, dict] = {}
|
||||
for game_id in managed_ids:
|
||||
product = self.api.get_product_info(game_id)
|
||||
if product:
|
||||
products_cache[game_id] = product
|
||||
|
||||
self._run_check(products_cache, owned_set)
|
||||
|
||||
def check_updates_from_cache(self, products_cache: dict[str, dict], owned_set: set[str]) -> None:
|
||||
"""Populate status using products already fetched by Library refresh (no API calls)."""
|
||||
if not self.config.managed_game_ids:
|
||||
return
|
||||
self.check_btn.setEnabled(False)
|
||||
self.status_label.setText("Checking updates...")
|
||||
self._update_platform_tab_visibility()
|
||||
self._verify_metadata()
|
||||
self._run_check(products_cache, owned_set)
|
||||
|
||||
def _verify_metadata(self) -> None:
|
||||
for platform in InstallerPlatform:
|
||||
base_path = self._base_path_for(platform)
|
||||
if base_path:
|
||||
@@ -170,16 +188,19 @@ class StatusTab(QWidget):
|
||||
if stale:
|
||||
logger.info(f"Cleaned {stale} stale metadata entries from {base_path}")
|
||||
|
||||
owned_set = self.api.get_owned_ids_set()
|
||||
def _run_check(self, products_cache: dict[str, dict], owned_set: set[str]) -> None:
|
||||
managed_ids = self.config.managed_game_ids
|
||||
managed_set = set(managed_ids)
|
||||
|
||||
# First pass: collect all DLC IDs and cache products
|
||||
for items in self.status_items.values():
|
||||
items.clear()
|
||||
self.tree_windows.clear()
|
||||
self.tree_linux.clear()
|
||||
|
||||
dlc_ids: set[str] = set()
|
||||
products_cache: dict[str, dict] = {}
|
||||
for game_id in managed_ids:
|
||||
product = self.api.get_product_info(game_id)
|
||||
product = products_cache.get(game_id)
|
||||
if product:
|
||||
products_cache[game_id] = product
|
||||
for dlc in product.get("expanded_dlcs", []):
|
||||
dlc_ids.add(str(dlc.get("id", "")))
|
||||
|
||||
@@ -314,6 +335,10 @@ class StatusTab(QWidget):
|
||||
elif result == CompareResult.NEWER:
|
||||
return GameStatus.UP_TO_DATE
|
||||
else:
|
||||
cache_key = f"{normalize_version(current)}|||{normalize_version(latest)}"
|
||||
if cache_key in self.config.version_decisions:
|
||||
logger.debug(f"Version decision cache hit: '{current}' vs '{latest}'")
|
||||
return GameStatus.UPDATE_AVAILABLE if self.config.version_decisions[cache_key] else GameStatus.UP_TO_DATE
|
||||
question = format_comparison_question(game_name, language, current, latest)
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
@@ -321,9 +346,10 @@ class StatusTab(QWidget):
|
||||
question,
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
return GameStatus.UPDATE_AVAILABLE
|
||||
return GameStatus.UP_TO_DATE
|
||||
is_newer = reply == QMessageBox.StandardButton.Yes
|
||||
self.config.version_decisions[cache_key] = is_newer
|
||||
self.config.save()
|
||||
return GameStatus.UPDATE_AVAILABLE if is_newer else GameStatus.UP_TO_DATE
|
||||
|
||||
def _worst_status(self, lang_infos: list[LanguageVersionInfo]) -> GameStatus:
|
||||
priority = [
|
||||
@@ -424,62 +450,54 @@ class StatusTab(QWidget):
|
||||
|
||||
return tree_item
|
||||
|
||||
def download_updates(self) -> None:
|
||||
"""Download updates for the currently visible platform."""
|
||||
platform = self._current_platform()
|
||||
def _run_download_for_platform(self, platform: InstallerPlatform) -> tuple[int, int]:
|
||||
"""Download all pending installers and bonus content for one platform.
|
||||
|
||||
Returns (items_downloaded, bonus_files_downloaded).
|
||||
"""
|
||||
base_path = self._base_path_for(platform)
|
||||
if not base_path:
|
||||
self.status_label.setText("No path configured for this platform.")
|
||||
return
|
||||
return 0, 0
|
||||
|
||||
downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED}
|
||||
to_download = [
|
||||
item for item in self.status_items[platform]
|
||||
if item.status in downloadable
|
||||
]
|
||||
to_download = [item for item in self.status_items[platform] if item.status in downloadable]
|
||||
for item in self.status_items[platform]:
|
||||
to_download.extend(dlc for dlc in item.dlcs if dlc.status in downloadable)
|
||||
|
||||
if not to_download:
|
||||
self.status_label.setText("Nothing to download.")
|
||||
return
|
||||
|
||||
self.download_btn.setEnabled(False)
|
||||
self.check_btn.setEnabled(False)
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setMaximum(len(to_download))
|
||||
self.progress_bar.setValue(0)
|
||||
|
||||
# TODO: Run in a thread to avoid blocking the GUI
|
||||
completed = 0
|
||||
metadata = MetadataStore(base_path)
|
||||
downloader = InstallerDownloader(self.api, metadata)
|
||||
if to_download:
|
||||
metadata = MetadataStore(base_path)
|
||||
downloader = InstallerDownloader(self.api, metadata)
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setMaximum(len(to_download))
|
||||
self.progress_bar.setValue(0)
|
||||
|
||||
for item in to_download:
|
||||
self.status_label.setText(f"Downloading: {item.name}...")
|
||||
folder_name = item.parent_name if item.parent_name else item.name
|
||||
effective_langs = self.config.get_effective_languages(item.game_id)
|
||||
installers = self.api.get_installers(item.game_id, platforms=[platform], languages=effective_langs)
|
||||
for item in to_download:
|
||||
self.status_label.setText(f"Downloading: {item.name}...")
|
||||
folder_name = item.parent_name if item.parent_name else item.name
|
||||
effective_langs = self.config.get_effective_languages(item.game_id)
|
||||
installers = self.api.get_installers(item.game_id, platforms=[platform], languages=effective_langs)
|
||||
|
||||
gs = self.config.get_game_settings(item.game_id)
|
||||
is_english_only = gs.english_only if gs.english_only is not None else self.config.english_only
|
||||
single_language = is_english_only or len({i.language for i in installers}) == 1
|
||||
gs = self.config.get_game_settings(item.game_id)
|
||||
is_english_only = gs.english_only if gs.english_only is not None else self.config.english_only
|
||||
single_language = is_english_only or len({i.language for i in installers}) == 1
|
||||
|
||||
for installer in installers:
|
||||
success = downloader.download_installer(installer, folder_name, single_language=single_language)
|
||||
if success:
|
||||
logger.info(f"Downloaded {installer.filename}")
|
||||
else:
|
||||
logger.error(f"Failed to download {installer.filename}")
|
||||
for installer in installers:
|
||||
success = downloader.download_installer(installer, folder_name, single_language=single_language)
|
||||
if success:
|
||||
logger.info(f"Downloaded {installer.filename}")
|
||||
else:
|
||||
logger.error(f"Failed to download {installer.filename}")
|
||||
|
||||
completed += 1
|
||||
self.progress_bar.setValue(completed)
|
||||
completed += 1
|
||||
self.progress_bar.setValue(completed)
|
||||
|
||||
# Bonus content for this platform
|
||||
bonus_count = 0
|
||||
if any(item.bonus_available > item.bonus_downloaded for item in self.status_items[platform]):
|
||||
bonus_items_to_download = self._collect_bonus_downloads(platform, base_path)
|
||||
if bonus_items_to_download:
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setMaximum(len(bonus_items_to_download))
|
||||
self.progress_bar.setValue(0)
|
||||
for game_name, bonus in bonus_items_to_download:
|
||||
@@ -491,6 +509,30 @@ class StatusTab(QWidget):
|
||||
self.progress_bar.setValue(bonus_count)
|
||||
|
||||
self.progress_bar.setVisible(False)
|
||||
return completed, bonus_count
|
||||
|
||||
def download_updates(self) -> None:
|
||||
"""Download updates for the currently visible platform."""
|
||||
platform = self._current_platform()
|
||||
if not self._base_path_for(platform):
|
||||
self.status_label.setText("No path configured for this platform.")
|
||||
return
|
||||
|
||||
downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED}
|
||||
has_items = any(
|
||||
item.status in downloadable or any(dlc.status in downloadable for dlc in item.dlcs)
|
||||
for item in self.status_items[platform]
|
||||
)
|
||||
has_bonus = any(item.bonus_available > item.bonus_downloaded for item in self.status_items[platform])
|
||||
if not has_items and not has_bonus:
|
||||
self.status_label.setText("Nothing to download.")
|
||||
return
|
||||
|
||||
self.download_btn.setEnabled(False)
|
||||
self.check_btn.setEnabled(False)
|
||||
|
||||
completed, bonus_count = self._run_download_for_platform(platform)
|
||||
|
||||
self.check_btn.setEnabled(True)
|
||||
parts = [f"Downloaded {completed} items"]
|
||||
if bonus_count:
|
||||
|
||||
@@ -18,10 +18,14 @@ _GOG_SUFFIX_RE = re.compile(r"\s*\(.*?\)\s*$")
|
||||
|
||||
|
||||
def _normalize(version: str) -> str:
|
||||
"""Strip GOG-specific suffixes and whitespace."""
|
||||
return _GOG_SUFFIX_RE.sub("", version).strip()
|
||||
|
||||
|
||||
def normalize_version(version: str) -> str:
|
||||
"""Strip GOG-specific suffixes and whitespace for use as a cache key."""
|
||||
return _normalize(version)
|
||||
|
||||
|
||||
def _parse_numeric_parts(version: str) -> list[int] | None:
|
||||
"""Try to parse version as dot-separated integers. Returns None if not possible."""
|
||||
normalized = _normalize(version)
|
||||
|
||||
Reference in New Issue
Block a user