Add version decision cache and library-to-status propagation
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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
|
## [1.0.0] — 2026-04-09
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "gogupdater"
|
name = "gogupdater"
|
||||||
version = "1.0.0"
|
version = "1.0.1"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"}
|
{name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"}
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
"""Auto-generated — do not edit manually."""
|
"""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.english_only: bool = False
|
||||||
self.include_bonus: bool = False
|
self.include_bonus: bool = False
|
||||||
self.game_settings: dict[str, GameSettings] = {}
|
self.game_settings: dict[str, GameSettings] = {}
|
||||||
|
self.version_decisions: dict[str, bool] = {}
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
def _load(self) -> None:
|
def _load(self) -> None:
|
||||||
@@ -41,6 +42,7 @@ class AppConfig:
|
|||||||
gid: GameSettings.from_dict(gs)
|
gid: GameSettings.from_dict(gs)
|
||||||
for gid, gs in data.get("game_settings", {}).items()
|
for gid, gs in data.get("game_settings", {}).items()
|
||||||
}
|
}
|
||||||
|
self.version_decisions = data.get("version_decisions", {})
|
||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
logger.warning("Failed to read config.json, using defaults")
|
logger.warning("Failed to read config.json, using defaults")
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ class AppConfig:
|
|||||||
for gid, gs in self.game_settings.items()
|
for gid, gs in self.game_settings.items()
|
||||||
if not gs.is_default()
|
if not gs.is_default()
|
||||||
},
|
},
|
||||||
|
"version_decisions": self.version_decisions,
|
||||||
}
|
}
|
||||||
self.config_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
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:
|
def _connect_signals(self) -> None:
|
||||||
self.auth_tab.login_state_changed.connect(self._on_login_changed)
|
self.auth_tab.login_state_changed.connect(self._on_login_changed)
|
||||||
self.settings_tab.english_only_changed.connect(self._on_english_only_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:
|
def _on_login_changed(self, logged_in: bool) -> None:
|
||||||
logger.info(f"Login state changed: logged_in={logged_in}")
|
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."""
|
"""Tab showing owned games with checkboxes. DLCs appear as children of their parent game."""
|
||||||
|
|
||||||
managed_games_changed = Signal()
|
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:
|
def __init__(self, api: GogApi, config: AppConfig, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -99,6 +100,7 @@ class LibraryTab(QWidget):
|
|||||||
# games_data shared for all platforms — platform filtering happens in populate
|
# games_data shared for all platforms — platform filtering happens in populate
|
||||||
games_data: list[tuple[str, str, list[tuple[str, str]], set[str]]] = []
|
games_data: list[tuple[str, str, list[tuple[str, str]], set[str]]] = []
|
||||||
# (game_id, title, [(dlc_id, dlc_title)], available_platforms)
|
# (game_id, title, [(dlc_id, dlc_title)], available_platforms)
|
||||||
|
products_cache: dict[str, dict] = {}
|
||||||
|
|
||||||
for gid in owned_ids:
|
for gid in owned_ids:
|
||||||
product = self.api.get_product_info(gid)
|
product = self.api.get_product_info(gid)
|
||||||
@@ -106,6 +108,7 @@ class LibraryTab(QWidget):
|
|||||||
games_data.append((str(gid), f"Game {gid}", [], set()))
|
games_data.append((str(gid), f"Game {gid}", [], set()))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
products_cache[str(gid)] = product
|
||||||
title = product.get("title", f"Game {gid}")
|
title = product.get("title", f"Game {gid}")
|
||||||
dlcs: list[tuple[str, str]] = []
|
dlcs: list[tuple[str, str]] = []
|
||||||
for dlc in product.get("expanded_dlcs", []):
|
for dlc in product.get("expanded_dlcs", []):
|
||||||
@@ -159,6 +162,7 @@ class LibraryTab(QWidget):
|
|||||||
self.status_label.setText(
|
self.status_label.setText(
|
||||||
f"Loaded {win_count} Windows / {lin_count} Linux games. Double-click to configure per-game settings."
|
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:
|
def _make_node(self, game_id: str, title: str, check: Qt.CheckState) -> QTreeWidgetItem:
|
||||||
node = QTreeWidgetItem()
|
node = QTreeWidgetItem()
|
||||||
|
|||||||
+94
-52
@@ -33,7 +33,7 @@ from src.models import (
|
|||||||
LanguageVersionInfo,
|
LanguageVersionInfo,
|
||||||
language_folder_name,
|
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] = {
|
STATUS_COLORS: dict[GameStatus, str] = {
|
||||||
GameStatus.UP_TO_DATE: "#2e7d32",
|
GameStatus.UP_TO_DATE: "#2e7d32",
|
||||||
@@ -90,6 +90,7 @@ class StatusTab(QWidget):
|
|||||||
self.download_btn.setEnabled(False)
|
self.download_btn.setEnabled(False)
|
||||||
controls.addWidget(self.download_btn)
|
controls.addWidget(self.download_btn)
|
||||||
|
|
||||||
|
|
||||||
self.prune_btn = QPushButton("Prune Old Versions")
|
self.prune_btn = QPushButton("Prune Old Versions")
|
||||||
self.prune_btn.clicked.connect(self.prune_versions)
|
self.prune_btn.clicked.connect(self.prune_versions)
|
||||||
controls.addWidget(self.prune_btn)
|
controls.addWidget(self.prune_btn)
|
||||||
@@ -153,15 +154,32 @@ class StatusTab(QWidget):
|
|||||||
|
|
||||||
self.check_btn.setEnabled(False)
|
self.check_btn.setEnabled(False)
|
||||||
self.status_label.setText("Checking updates...")
|
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()
|
self._update_platform_tab_visibility()
|
||||||
|
|
||||||
# TODO: Run in a thread to avoid blocking the GUI
|
# 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:
|
for platform in InstallerPlatform:
|
||||||
base_path = self._base_path_for(platform)
|
base_path = self._base_path_for(platform)
|
||||||
if base_path:
|
if base_path:
|
||||||
@@ -170,16 +188,19 @@ class StatusTab(QWidget):
|
|||||||
if stale:
|
if stale:
|
||||||
logger.info(f"Cleaned {stale} stale metadata entries from {base_path}")
|
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)
|
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()
|
dlc_ids: set[str] = set()
|
||||||
products_cache: dict[str, dict] = {}
|
|
||||||
for game_id in managed_ids:
|
for game_id in managed_ids:
|
||||||
product = self.api.get_product_info(game_id)
|
product = products_cache.get(game_id)
|
||||||
if product:
|
if product:
|
||||||
products_cache[game_id] = product
|
|
||||||
for dlc in product.get("expanded_dlcs", []):
|
for dlc in product.get("expanded_dlcs", []):
|
||||||
dlc_ids.add(str(dlc.get("id", "")))
|
dlc_ids.add(str(dlc.get("id", "")))
|
||||||
|
|
||||||
@@ -314,6 +335,10 @@ class StatusTab(QWidget):
|
|||||||
elif result == CompareResult.NEWER:
|
elif result == CompareResult.NEWER:
|
||||||
return GameStatus.UP_TO_DATE
|
return GameStatus.UP_TO_DATE
|
||||||
else:
|
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)
|
question = format_comparison_question(game_name, language, current, latest)
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
@@ -321,9 +346,10 @@ class StatusTab(QWidget):
|
|||||||
question,
|
question,
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.StandardButton.Yes:
|
is_newer = reply == QMessageBox.StandardButton.Yes
|
||||||
return GameStatus.UPDATE_AVAILABLE
|
self.config.version_decisions[cache_key] = is_newer
|
||||||
return GameStatus.UP_TO_DATE
|
self.config.save()
|
||||||
|
return GameStatus.UPDATE_AVAILABLE if is_newer else GameStatus.UP_TO_DATE
|
||||||
|
|
||||||
def _worst_status(self, lang_infos: list[LanguageVersionInfo]) -> GameStatus:
|
def _worst_status(self, lang_infos: list[LanguageVersionInfo]) -> GameStatus:
|
||||||
priority = [
|
priority = [
|
||||||
@@ -424,62 +450,54 @@ class StatusTab(QWidget):
|
|||||||
|
|
||||||
return tree_item
|
return tree_item
|
||||||
|
|
||||||
def download_updates(self) -> None:
|
def _run_download_for_platform(self, platform: InstallerPlatform) -> tuple[int, int]:
|
||||||
"""Download updates for the currently visible platform."""
|
"""Download all pending installers and bonus content for one platform.
|
||||||
platform = self._current_platform()
|
|
||||||
|
Returns (items_downloaded, bonus_files_downloaded).
|
||||||
|
"""
|
||||||
base_path = self._base_path_for(platform)
|
base_path = self._base_path_for(platform)
|
||||||
if not base_path:
|
if not base_path:
|
||||||
self.status_label.setText("No path configured for this platform.")
|
return 0, 0
|
||||||
return
|
|
||||||
|
|
||||||
downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED}
|
downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED}
|
||||||
to_download = [
|
to_download = [item for item in self.status_items[platform] if item.status in downloadable]
|
||||||
item for item in self.status_items[platform]
|
|
||||||
if item.status in downloadable
|
|
||||||
]
|
|
||||||
for item in self.status_items[platform]:
|
for item in self.status_items[platform]:
|
||||||
to_download.extend(dlc for dlc in item.dlcs if dlc.status in downloadable)
|
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
|
# TODO: Run in a thread to avoid blocking the GUI
|
||||||
completed = 0
|
completed = 0
|
||||||
metadata = MetadataStore(base_path)
|
if to_download:
|
||||||
downloader = InstallerDownloader(self.api, metadata)
|
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:
|
for item in to_download:
|
||||||
self.status_label.setText(f"Downloading: {item.name}...")
|
self.status_label.setText(f"Downloading: {item.name}...")
|
||||||
folder_name = item.parent_name if item.parent_name else item.name
|
folder_name = item.parent_name if item.parent_name else item.name
|
||||||
effective_langs = self.config.get_effective_languages(item.game_id)
|
effective_langs = self.config.get_effective_languages(item.game_id)
|
||||||
installers = self.api.get_installers(item.game_id, platforms=[platform], languages=effective_langs)
|
installers = self.api.get_installers(item.game_id, platforms=[platform], languages=effective_langs)
|
||||||
|
|
||||||
gs = self.config.get_game_settings(item.game_id)
|
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
|
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
|
single_language = is_english_only or len({i.language for i in installers}) == 1
|
||||||
|
|
||||||
for installer in installers:
|
for installer in installers:
|
||||||
success = downloader.download_installer(installer, folder_name, single_language=single_language)
|
success = downloader.download_installer(installer, folder_name, single_language=single_language)
|
||||||
if success:
|
if success:
|
||||||
logger.info(f"Downloaded {installer.filename}")
|
logger.info(f"Downloaded {installer.filename}")
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to download {installer.filename}")
|
logger.error(f"Failed to download {installer.filename}")
|
||||||
|
|
||||||
completed += 1
|
completed += 1
|
||||||
self.progress_bar.setValue(completed)
|
self.progress_bar.setValue(completed)
|
||||||
|
|
||||||
# Bonus content for this platform
|
|
||||||
bonus_count = 0
|
bonus_count = 0
|
||||||
if any(item.bonus_available > item.bonus_downloaded for item in self.status_items[platform]):
|
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)
|
bonus_items_to_download = self._collect_bonus_downloads(platform, base_path)
|
||||||
if bonus_items_to_download:
|
if bonus_items_to_download:
|
||||||
|
self.progress_bar.setVisible(True)
|
||||||
self.progress_bar.setMaximum(len(bonus_items_to_download))
|
self.progress_bar.setMaximum(len(bonus_items_to_download))
|
||||||
self.progress_bar.setValue(0)
|
self.progress_bar.setValue(0)
|
||||||
for game_name, bonus in bonus_items_to_download:
|
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.setValue(bonus_count)
|
||||||
|
|
||||||
self.progress_bar.setVisible(False)
|
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)
|
self.check_btn.setEnabled(True)
|
||||||
parts = [f"Downloaded {completed} items"]
|
parts = [f"Downloaded {completed} items"]
|
||||||
if bonus_count:
|
if bonus_count:
|
||||||
|
|||||||
@@ -18,10 +18,14 @@ _GOG_SUFFIX_RE = re.compile(r"\s*\(.*?\)\s*$")
|
|||||||
|
|
||||||
|
|
||||||
def _normalize(version: str) -> str:
|
def _normalize(version: str) -> str:
|
||||||
"""Strip GOG-specific suffixes and whitespace."""
|
|
||||||
return _GOG_SUFFIX_RE.sub("", version).strip()
|
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:
|
def _parse_numeric_parts(version: str) -> list[int] | None:
|
||||||
"""Try to parse version as dot-separated integers. Returns None if not possible."""
|
"""Try to parse version as dot-separated integers. Returns None if not possible."""
|
||||||
normalized = _normalize(version)
|
normalized = _normalize(version)
|
||||||
|
|||||||
Reference in New Issue
Block a user