Initial release — PySide6 app for automatic GOG offline installer management
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Reference codebase
|
||||||
|
heroic-gogdl-main/
|
||||||
|
|
||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# AI agent documentation (not for commit per AGENTS.md)
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
DESIGN_DOCUMENT.md
|
||||||
|
|
||||||
|
# Python
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
24
CHANGELOG.md
Normal file
24
CHANGELOG.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-04-09
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- GOG OAuth2 authentication — browser-based login, paste authorization code
|
||||||
|
- Game library browser with checkboxes for management, DLCs as sub-items
|
||||||
|
- Windows / Linux platform switcher in Library and Status tabs (games shown only for platforms where installers exist)
|
||||||
|
- Per-language installer management — each language tracked independently, version comparison per language
|
||||||
|
- Language selection tab — choose which languages to download globally
|
||||||
|
- English-only mode — single toggle to skip language subfolders entirely
|
||||||
|
- Bonus content support — soundtracks, wallpapers, manuals etc. stored in `GameName/Bonus/`
|
||||||
|
- Per-game settings override — double-click any game in Library to configure languages, english-only and bonus per game
|
||||||
|
- Folder name sanitization — strips ®, ™, replaces `:` with ` - `, collapses spaces
|
||||||
|
- Version comparison — numeric dot-separated comparison with GOG suffix stripping; ambiguous versions ask user via dialog
|
||||||
|
- Status tab — per-language version display, color-coded status (up to date, update available, not downloaded, unversioned)
|
||||||
|
- Download with resume support (HTTP Range), real filename extracted from CDN URL
|
||||||
|
- Prune old versions — keep N most recent, configurable per platform
|
||||||
|
- Metadata integrity check — stale entries (manually deleted files) cleaned automatically on each check
|
||||||
|
- Scan existing installers — detect already-downloaded files from correct folder structure and import into metadata
|
||||||
|
- Version history dialog — double-click game in Status tab to see all downloaded versions with delete options
|
||||||
|
- DLC installers stored in parent game's folder, not separate folders
|
||||||
|
- Windows-only games do not download bonus content to Linux path and vice versa
|
||||||
34
GOGUpdater.py
Normal file
34
GOGUpdater.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""GOGUpdater GUI entry point."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from src.api import GogApi
|
||||||
|
from src.auth import AuthManager
|
||||||
|
from src.config import AppConfig, DEFAULT_CONFIG_DIR
|
||||||
|
from src.constants import APP_TITLE
|
||||||
|
from src.ui.main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
config_dir = DEFAULT_CONFIG_DIR
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logger.info(f"Starting {APP_TITLE}")
|
||||||
|
|
||||||
|
auth = AuthManager(config_dir)
|
||||||
|
api = GogApi(auth)
|
||||||
|
config = AppConfig(config_dir)
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
window = MainWindow(auth, api, config)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
logger.info("Application window shown")
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
131
PROJECT.md
Normal file
131
PROJECT.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# GOGUpdater
|
||||||
|
|
||||||
|
Desktop application (PySide6) for automatic management of GOG.com offline installers.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
GOGUpdater authenticates with a GOG account via OAuth2, lets the user select which games to manage, and automatically downloads/updates offline installers (Windows .exe, Linux .sh) to local directories. The user has a clear overview of installer status and can trigger checks and downloads manually.
|
||||||
|
|
||||||
|
## Current version
|
||||||
|
|
||||||
|
**1.0.0** (2026-04-09)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### GUI — 5 tabs
|
||||||
|
|
||||||
|
1. **Login** — OAuth2 flow, opens login URL in system browser, user pastes authorization code
|
||||||
|
2. **Library** — owned games with management checkboxes, DLCs as sub-items; Windows/Linux platform switcher; double-click opens per-game settings
|
||||||
|
3. **Languages** — select which languages to download globally; disabled when English-only mode is on
|
||||||
|
4. **Settings** — Windows/Linux installer paths, English-only toggle, bonus content toggle, scan existing installers
|
||||||
|
5. **Status** — installer status per platform (Windows/Linux switcher), check/download/prune controls; double-click opens version history dialog
|
||||||
|
|
||||||
|
### Installer folder structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/path/Game Name/1.63/English/setup_game.exe
|
||||||
|
/path/Game Name/1.63/Czech/setup_game_cs.exe
|
||||||
|
/path/Game Name/1.52/English/setup_game.exe ← old version kept
|
||||||
|
/path/Game Name/Bonus/soundtrack.zip ← bonus content (no version subfolder)
|
||||||
|
```
|
||||||
|
|
||||||
|
Single-language games and English-only mode skip the language subfolder:
|
||||||
|
```
|
||||||
|
/path/Game Name/1.63/setup_game.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
DLC installers are stored inside the parent game's folder.
|
||||||
|
|
||||||
|
### Folder name sanitization
|
||||||
|
|
||||||
|
Game titles are sanitized before use as folder names:
|
||||||
|
- `:` → ` - `
|
||||||
|
- Strips `®`, `™`, `©`
|
||||||
|
- Removes invalid filesystem characters
|
||||||
|
- Collapses multiple spaces
|
||||||
|
|
||||||
|
### Metadata
|
||||||
|
|
||||||
|
Each target path (windows/linux) contains `gogupdater.json` tracking downloaded installers and bonus files. Stale entries (files deleted outside the app) are cleaned automatically on each status check.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"games": {
|
||||||
|
"1207658691": {
|
||||||
|
"name": "Cyberpunk 2077",
|
||||||
|
"latest_version": "1.63",
|
||||||
|
"managed": true,
|
||||||
|
"installers": [
|
||||||
|
{
|
||||||
|
"filename": "setup_cyberpunk_2077_1.63.exe",
|
||||||
|
"size": 12345678,
|
||||||
|
"version": "1.63",
|
||||||
|
"language": "en",
|
||||||
|
"installer_type": "game",
|
||||||
|
"downloaded_at": "2026-04-09T10:00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bonuses": [],
|
||||||
|
"last_checked": "2026-04-09T10:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Application configuration
|
||||||
|
|
||||||
|
Stored in `~/.config/gogupdater/`:
|
||||||
|
- `auth.json` — OAuth2 tokens
|
||||||
|
- `config.json` — paths, languages, managed games, english_only, include_bonus, per-game overrides
|
||||||
|
|
||||||
|
### GOG API endpoints
|
||||||
|
|
||||||
|
- `GET /user/data/games` — list of owned game IDs
|
||||||
|
- `GET /products/{id}?expand=downloads,expanded_dlcs` — game info, installers, bonus content, DLCs
|
||||||
|
- `POST https://auth.gog.com/token` — OAuth2 authentication and refresh
|
||||||
|
|
||||||
|
### Download URL resolution
|
||||||
|
|
||||||
|
GOG uses a two-level redirect: API downlink → JSON with CDN URL → actual file. The real filename is extracted from the CDN URL path (e.g. `setup_fallout_2.1.0.18.exe`).
|
||||||
|
|
||||||
|
### Version comparison
|
||||||
|
|
||||||
|
- Strips GOG suffixes like `(gog-3)` before comparing
|
||||||
|
- Numeric dot-separated comparison
|
||||||
|
- Ambiguous cases (e.g. `2.2(gog-3)` vs `2.3`) prompt the user via dialog
|
||||||
|
- Non-version strings like `"bonus"` are treated as unversioned
|
||||||
|
|
||||||
|
### Per-game settings
|
||||||
|
|
||||||
|
Each game can override global settings:
|
||||||
|
- Languages (which languages to download)
|
||||||
|
- English-only (skip language subfolder)
|
||||||
|
- Include bonus content
|
||||||
|
|
||||||
|
Stored as `game_settings` in `config.json`. Default (no override) entries are not saved.
|
||||||
|
|
||||||
|
## Modules (src/)
|
||||||
|
|
||||||
|
- `auth.py` — authentication, token management
|
||||||
|
- `api.py` — GOG API client (installers, bonus content, owned games)
|
||||||
|
- `config.py` — application config, metadata store, scan/verify existing installers
|
||||||
|
- `downloader.py` — installer and bonus file downloads with resume support
|
||||||
|
- `models.py` — data structures (GameRecord, InstallerInfo, BonusContent, GameSettings, ...)
|
||||||
|
- `version_compare.py` — version comparison with ambiguous case detection
|
||||||
|
- `constants.py` — version, app title, debug mode
|
||||||
|
- `ui/main_window.py` — main window with tab layout
|
||||||
|
- `ui/tab_auth.py` — login tab
|
||||||
|
- `ui/tab_library.py` — library tab with platform switcher
|
||||||
|
- `ui/tab_languages.py` — language selection tab
|
||||||
|
- `ui/tab_settings.py` — settings tab
|
||||||
|
- `ui/tab_status.py` — status tab with platform switcher
|
||||||
|
- `ui/dialog_game_settings.py` — per-game settings dialog
|
||||||
|
- `ui/dialog_game_versions.py` — downloaded version history dialog
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- PySide6
|
||||||
|
- requests
|
||||||
|
- python-dotenv
|
||||||
|
- loguru
|
||||||
|
- tqdm
|
||||||
712
poetry.lock
generated
Normal file
712
poetry.lock
generated
Normal file
@@ -0,0 +1,712 @@
|
|||||||
|
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.2.25"
|
||||||
|
description = "Python package for providing Mozilla's CA Bundle."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"},
|
||||||
|
{file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.6"
|
||||||
|
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-win32.whl", hash = "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp38-cp38-win_amd64.whl", hash = "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-win32.whl", hash = "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8"},
|
||||||
|
{file = "charset_normalizer-3.4.6-cp39-cp39-win_arm64.whl", hash = "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8"},
|
||||||
|
{file = "charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69"},
|
||||||
|
{file = "charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
description = "Cross-platform colored terminal text."
|
||||||
|
optional = false
|
||||||
|
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||||
|
groups = ["main", "dev"]
|
||||||
|
files = [
|
||||||
|
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||||
|
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||||
|
]
|
||||||
|
markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "sys_platform == \"win32\""}
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.11"
|
||||||
|
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
|
||||||
|
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
description = "brain-dead simple config-ini parsing"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||||
|
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "librt"
|
||||||
|
version = "0.8.1"
|
||||||
|
description = "Mypyc runtime library"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
|
markers = "platform_python_implementation != \"PyPy\""
|
||||||
|
files = [
|
||||||
|
{file = "librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc"},
|
||||||
|
{file = "librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7"},
|
||||||
|
{file = "librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6"},
|
||||||
|
{file = "librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0"},
|
||||||
|
{file = "librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b"},
|
||||||
|
{file = "librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6"},
|
||||||
|
{file = "librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71"},
|
||||||
|
{file = "librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7"},
|
||||||
|
{file = "librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05"},
|
||||||
|
{file = "librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891"},
|
||||||
|
{file = "librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7"},
|
||||||
|
{file = "librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2"},
|
||||||
|
{file = "librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd"},
|
||||||
|
{file = "librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965"},
|
||||||
|
{file = "librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da"},
|
||||||
|
{file = "librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0"},
|
||||||
|
{file = "librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e"},
|
||||||
|
{file = "librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3"},
|
||||||
|
{file = "librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac"},
|
||||||
|
{file = "librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596"},
|
||||||
|
{file = "librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99"},
|
||||||
|
{file = "librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe"},
|
||||||
|
{file = "librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb"},
|
||||||
|
{file = "librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b"},
|
||||||
|
{file = "librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9"},
|
||||||
|
{file = "librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a"},
|
||||||
|
{file = "librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9"},
|
||||||
|
{file = "librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb"},
|
||||||
|
{file = "librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d"},
|
||||||
|
{file = "librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7"},
|
||||||
|
{file = "librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440"},
|
||||||
|
{file = "librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9"},
|
||||||
|
{file = "librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972"},
|
||||||
|
{file = "librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921"},
|
||||||
|
{file = "librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0"},
|
||||||
|
{file = "librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a"},
|
||||||
|
{file = "librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444"},
|
||||||
|
{file = "librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d"},
|
||||||
|
{file = "librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35"},
|
||||||
|
{file = "librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583"},
|
||||||
|
{file = "librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c"},
|
||||||
|
{file = "librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04"},
|
||||||
|
{file = "librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363"},
|
||||||
|
{file = "librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0"},
|
||||||
|
{file = "librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012"},
|
||||||
|
{file = "librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb"},
|
||||||
|
{file = "librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b"},
|
||||||
|
{file = "librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d"},
|
||||||
|
{file = "librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a"},
|
||||||
|
{file = "librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79"},
|
||||||
|
{file = "librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4"},
|
||||||
|
{file = "librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61"},
|
||||||
|
{file = "librt-0.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3dff3d3ca8db20e783b1bc7de49c0a2ab0b8387f31236d6a026597d07fcd68ac"},
|
||||||
|
{file = "librt-0.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08eec3a1fc435f0d09c87b6bf1ec798986a3544f446b864e4099633a56fcd9ed"},
|
||||||
|
{file = "librt-0.8.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3f0a41487fd5fad7e760b9e8a90e251e27c2816fbc2cff36a22a0e6bcbbd9dd"},
|
||||||
|
{file = "librt-0.8.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bacdb58d9939d95cc557b4dbaa86527c9db2ac1ed76a18bc8d26f6dc8647d851"},
|
||||||
|
{file = "librt-0.8.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d7ab1f01aa753188605b09a51faa44a3327400b00b8cce424c71910fc0a128"},
|
||||||
|
{file = "librt-0.8.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4998009e7cb9e896569f4be7004f09d0ed70d386fa99d42b6d363f6d200501ac"},
|
||||||
|
{file = "librt-0.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2cc68eeeef5e906839c7bb0815748b5b0a974ec27125beefc0f942715785b551"},
|
||||||
|
{file = "librt-0.8.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0bf69d79a23f4f40b8673a947a234baeeb133b5078b483b7297c5916539cf5d5"},
|
||||||
|
{file = "librt-0.8.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:22b46eabd76c1986ee7d231b0765ad387d7673bbd996aa0d0d054b38ac65d8f6"},
|
||||||
|
{file = "librt-0.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:237796479f4d0637d6b9cbcb926ff424a97735e68ade6facf402df4ec93375ed"},
|
||||||
|
{file = "librt-0.8.1-cp39-cp39-win32.whl", hash = "sha256:4beb04b8c66c6ae62f8c1e0b2f097c1ebad9295c929a8d5286c05eae7c2fc7dc"},
|
||||||
|
{file = "librt-0.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:64548cde61b692dc0dc379f4b5f59a2f582c2ebe7890d09c1ae3b9e66fa015b7"},
|
||||||
|
{file = "librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "loguru"
|
||||||
|
version = "0.7.3"
|
||||||
|
description = "Python logging made (stupidly) simple"
|
||||||
|
optional = false
|
||||||
|
python-versions = "<4.0,>=3.5"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"},
|
||||||
|
{file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
|
||||||
|
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy"
|
||||||
|
version = "1.19.1"
|
||||||
|
description = "Optional static typing for Python"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"},
|
||||||
|
{file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"},
|
||||||
|
{file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"},
|
||||||
|
{file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"},
|
||||||
|
{file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"},
|
||||||
|
{file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"},
|
||||||
|
{file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"},
|
||||||
|
{file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"},
|
||||||
|
{file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"},
|
||||||
|
{file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"},
|
||||||
|
{file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"},
|
||||||
|
{file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"},
|
||||||
|
{file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"},
|
||||||
|
{file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"},
|
||||||
|
{file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"},
|
||||||
|
{file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"},
|
||||||
|
{file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"},
|
||||||
|
{file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"},
|
||||||
|
{file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"},
|
||||||
|
{file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"},
|
||||||
|
{file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"},
|
||||||
|
{file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"},
|
||||||
|
{file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"},
|
||||||
|
{file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"},
|
||||||
|
{file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"},
|
||||||
|
{file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"},
|
||||||
|
{file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"},
|
||||||
|
{file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"},
|
||||||
|
{file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"},
|
||||||
|
{file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"},
|
||||||
|
{file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"},
|
||||||
|
{file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"},
|
||||||
|
{file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"},
|
||||||
|
{file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"},
|
||||||
|
{file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"},
|
||||||
|
{file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"},
|
||||||
|
{file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"},
|
||||||
|
{file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""}
|
||||||
|
mypy_extensions = ">=1.0.0"
|
||||||
|
pathspec = ">=0.9.0"
|
||||||
|
typing_extensions = ">=4.6.0"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dmypy = ["psutil (>=4.0)"]
|
||||||
|
faster-cache = ["orjson"]
|
||||||
|
install-types = ["pip"]
|
||||||
|
mypyc = ["setuptools (>=50)"]
|
||||||
|
reports = ["lxml"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy-extensions"
|
||||||
|
version = "1.1.0"
|
||||||
|
description = "Type system extensions for programs checked with the mypy type checker."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
|
||||||
|
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
description = "Core utilities for Python packages"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
|
||||||
|
{file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathspec"
|
||||||
|
version = "1.0.4"
|
||||||
|
description = "Utility library for gitignore style pattern matching of file paths."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"},
|
||||||
|
{file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
hyperscan = ["hyperscan (>=0.7)"]
|
||||||
|
optional = ["typing-extensions (>=4)"]
|
||||||
|
re2 = ["google-re2 (>=1.1)"]
|
||||||
|
tests = ["pytest (>=9)", "typing-extensions (>=4.15)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
description = "plugin and hook calling mechanisms for python"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
||||||
|
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["pre-commit", "tox"]
|
||||||
|
testing = ["coverage", "pytest", "pytest-benchmark"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
description = "Pygments is a syntax highlighting package written in Python."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
||||||
|
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
windows-terminal = ["colorama (>=0.4.6)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyside6"
|
||||||
|
version = "6.11.0"
|
||||||
|
description = "Python bindings for the Qt cross-platform application and UI framework"
|
||||||
|
optional = false
|
||||||
|
python-versions = "<3.15,>=3.10"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "pyside6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1f2735dc4f2bd4ec452ae50502c8a22128bba0aced35358a2bbc58384b820c6f"},
|
||||||
|
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c642e2d25704ca746fd37f56feacf25c5aecc4cd40bef23d18eec81f87d9dc00"},
|
||||||
|
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:267b344c73580ac938ca63c611881fb42a3922ebfe043e271005f4f06c372c4e"},
|
||||||
|
{file = "pyside6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:9092cb002ca43c64006afb2e0d0f6f51aef17aa737c33a45e502326a081ddcbc"},
|
||||||
|
{file = "pyside6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:b15f39acc2b8f46251a630acad0d97f9a0a0461f2baffcd66d7adfada8eb641e"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
PySide6_Addons = "6.11.0"
|
||||||
|
PySide6_Essentials = "6.11.0"
|
||||||
|
shiboken6 = "6.11.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyside6-addons"
|
||||||
|
version = "6.11.0"
|
||||||
|
description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
|
||||||
|
optional = false
|
||||||
|
python-versions = "<3.15,>=3.10"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "pyside6_addons-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d5eaa4643302e3a0fa94c5766234bee4073d7d5ab9c2b7fd222692a176faf182"},
|
||||||
|
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ac6fe3d4ef4497dde3efc5e896b0acd53ff6c93be4bf485f045690f919419f35"},
|
||||||
|
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8ffb40222456078930816ebcac2f2511716d2acbc11716dd5acc5c365179a753"},
|
||||||
|
{file = "pyside6_addons-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:413e6121c24f5ffdce376298059eddecff74aa6d638e94e0f6015b33d29b889e"},
|
||||||
|
{file = "pyside6_addons-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:aaaee83385977a0fe134b2f4fbfb92b45a880d5b656e4d90a708eef10b1b6de8"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
PySide6_Essentials = "6.11.0"
|
||||||
|
shiboken6 = "6.11.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyside6-essentials"
|
||||||
|
version = "6.11.0"
|
||||||
|
description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
|
||||||
|
optional = false
|
||||||
|
python-versions = "<3.15,>=3.10"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "pyside6_essentials-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:85d6ca87ef35fa6565d385ede72ae48420dd3f63113929d10fc800f6b0360e01"},
|
||||||
|
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc20e7afd5fc6fe51297db91cef997ce60844be578f7a49fc61b7ab9657a8849"},
|
||||||
|
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4854cb0a1b061e7a576d8fb7bb7cf9f49540d558b1acb7df0742a7afefe61e4e"},
|
||||||
|
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:3b3362882ad9389357a80504e600180006a957731fec05786fced7b038461fdf"},
|
||||||
|
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:81ca603dbf21bc39f89bb42db215c25ebe0c879a1a4c387625c321d2730ec187"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
shiboken6 = "6.11.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
description = "pytest: simple powerful testing with Python"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
|
||||||
|
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
|
||||||
|
iniconfig = ">=1.0.1"
|
||||||
|
packaging = ">=22"
|
||||||
|
pluggy = ">=1.5,<2"
|
||||||
|
pygments = ">=2.7.2"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-mock"
|
||||||
|
version = "3.15.1"
|
||||||
|
description = "Thin-wrapper around the mock package for easier use with pytest"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"},
|
||||||
|
{file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
pytest = ">=6.2.5"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["pre-commit", "pytest-asyncio", "tox"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.2"
|
||||||
|
description = "Read key-value pairs from a .env file and set them as environment variables"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
|
||||||
|
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
cli = ["click (>=5.0)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.33.0"
|
||||||
|
description = "Python HTTP for Humans."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"},
|
||||||
|
{file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
certifi = ">=2023.5.7"
|
||||||
|
charset_normalizer = ">=2,<4"
|
||||||
|
idna = ">=2.5,<4"
|
||||||
|
urllib3 = ">=1.26,<3"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
||||||
|
test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"]
|
||||||
|
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.15.7"
|
||||||
|
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1"},
|
||||||
|
{file = "ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2"},
|
||||||
|
{file = "ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shiboken6"
|
||||||
|
version = "6.11.0"
|
||||||
|
description = "Python/C++ bindings helper module"
|
||||||
|
optional = false
|
||||||
|
python-versions = "<3.15,>=3.10"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "shiboken6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d88e8a1eb705f2b9ad21db08a61ae1dc0c773e5cd86a069de0754c4cf1f9b43b"},
|
||||||
|
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad54e64f8192ddbdff0c54ac82b89edcd62ed623f502ea21c960541d19514053"},
|
||||||
|
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a10dc7718104ea2dc15d5b0b96909b77162ce1c76fcc6968e6df692b947a00e9"},
|
||||||
|
{file = "shiboken6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:483ff78a73c7b3189ca924abc694318084f078bcfeaffa68e32024ff2d025ee1"},
|
||||||
|
{file = "shiboken6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:3bd76cf56105ab2d62ecaff630366f11264f69b88d488f10f048da9a065781f4"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tqdm"
|
||||||
|
version = "4.67.3"
|
||||||
|
description = "Fast, Extensible Progress Meter"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf"},
|
||||||
|
{file = "tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"]
|
||||||
|
discord = ["requests"]
|
||||||
|
notebook = ["ipywidgets (>=6)"]
|
||||||
|
slack = ["slack-sdk"]
|
||||||
|
telegram = ["requests"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-requests"
|
||||||
|
version = "2.32.4.20260324"
|
||||||
|
description = "Typing stubs for requests"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "types_requests-2.32.4.20260324-py3-none-any.whl", hash = "sha256:f83ef2deb284fe99a249b8b0b0a3e4b9809e01ff456063c4df0aac7670c07ab9"},
|
||||||
|
{file = "types_requests-2.32.4.20260324.tar.gz", hash = "sha256:33a2a9ccb1de7d4e4da36e347622c35418f6761269014cc32857acabd5df739e"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
urllib3 = ">=2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||||
|
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.6.3"
|
||||||
|
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main", "dev"]
|
||||||
|
files = [
|
||||||
|
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
|
||||||
|
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
|
||||||
|
h2 = ["h2 (>=4,<5)"]
|
||||||
|
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||||
|
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "win32-setctime"
|
||||||
|
version = "1.2.0"
|
||||||
|
description = "A small Python utility to set file creation time on Windows"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.5"
|
||||||
|
groups = ["main"]
|
||||||
|
markers = "sys_platform == \"win32\""
|
||||||
|
files = [
|
||||||
|
{file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"},
|
||||||
|
{file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"]
|
||||||
|
|
||||||
|
[metadata]
|
||||||
|
lock-version = "2.1"
|
||||||
|
python-versions = ">=3.14,<3.15"
|
||||||
|
content-hash = "feafc9d9aeea619528d2ca9e876b943ca4c42ba21799f580ce473e4f32e2a38c"
|
||||||
66
prebuild.py
Normal file
66
prebuild.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from src.constants import VERSION
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("PREBUILD CONFIGURATION")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Check if running in virtual environment
|
||||||
|
project_root = Path(__file__).parent
|
||||||
|
expected_venv_path = project_root / ".venv"
|
||||||
|
current_executable = Path(sys.executable)
|
||||||
|
|
||||||
|
print(f"\nPython executable: {sys.executable}")
|
||||||
|
|
||||||
|
is_correct_venv = False
|
||||||
|
try:
|
||||||
|
current_executable.relative_to(expected_venv_path)
|
||||||
|
is_correct_venv = True
|
||||||
|
except ValueError:
|
||||||
|
is_correct_venv = False
|
||||||
|
|
||||||
|
if is_correct_venv:
|
||||||
|
print("✓ Correct environment selected for building")
|
||||||
|
else:
|
||||||
|
print("✗ Wrong environment selected")
|
||||||
|
print(f" Expected: {expected_venv_path}")
|
||||||
|
print(f" Current: {current_executable.parent.parent}")
|
||||||
|
|
||||||
|
print(f"✓ Version: {VERSION}")
|
||||||
|
|
||||||
|
env_debug = os.getenv("ENV_DEBUG", "false").lower() == "true"
|
||||||
|
console_mode = env_debug
|
||||||
|
default_spec = Path(__file__).parent.name + ".spec"
|
||||||
|
spec_filename = os.getenv("ENV_BUILD_SPEC", default_spec)
|
||||||
|
|
||||||
|
print(f"\n{'-' * 50}")
|
||||||
|
print("BUILD SETTINGS")
|
||||||
|
print(f"{'-' * 50}")
|
||||||
|
print(f"ENV_DEBUG: {env_debug}")
|
||||||
|
print(f"Console mode: {console_mode}")
|
||||||
|
print(f"Spec file: {spec_filename}")
|
||||||
|
|
||||||
|
spec_path = Path(__file__).parent / spec_filename
|
||||||
|
if spec_path.exists():
|
||||||
|
with open(spec_path, "r", encoding="utf-8") as f:
|
||||||
|
spec_content = f.read()
|
||||||
|
|
||||||
|
if f"console={not console_mode}" in spec_content:
|
||||||
|
new_spec_content = spec_content.replace(
|
||||||
|
f"console={not console_mode}",
|
||||||
|
f"console={console_mode}"
|
||||||
|
)
|
||||||
|
with open(spec_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(new_spec_content)
|
||||||
|
print(f"✓ Updated {spec_filename}: console={console_mode}")
|
||||||
|
else:
|
||||||
|
print(f"✓ {spec_filename} already configured: console={console_mode}")
|
||||||
|
else:
|
||||||
|
print(f"✗ {spec_filename} not found!")
|
||||||
|
|
||||||
|
print(f"{'-' * 50}\n")
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "gogupdater"
|
name = "gogupdater"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"}
|
{name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"}
|
||||||
]
|
]
|
||||||
requires-python = ">=3.14,<3.15"
|
requires-python = ">=3.14,<3.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"pyside6 (>=6.11.0,<7.0.0)",
|
||||||
|
"loguru (>=0.7.3,<0.8.0)",
|
||||||
|
"python-dotenv (>=1.2.2,<2.0.0)",
|
||||||
|
"requests (>=2.33.0,<3.0.0)",
|
||||||
|
"tqdm (>=4.67.3,<5.0.0)"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
@@ -15,3 +20,12 @@ package-mode = false
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ruff (>=0.15.7,<0.16.0)",
|
||||||
|
"mypy (>=1.19.1,<2.0.0)",
|
||||||
|
"pytest (>=9.0.2,<10.0.0)",
|
||||||
|
"pytest-mock (>=3.15.1,<4.0.0)",
|
||||||
|
"types-requests (>=2.32.4.20260324,<3.0.0.0)"
|
||||||
|
]
|
||||||
|
|||||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
2
src/_version.py
Normal file
2
src/_version.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Auto-generated — do not edit manually."""
|
||||||
|
__version__ = "0.1.0"
|
||||||
250
src/api.py
Normal file
250
src/api.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"""GOG API client for fetching game library and installer info."""
|
||||||
|
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.auth import AuthManager
|
||||||
|
from src.models import BonusContent, InstallerInfo, InstallerPlatform, InstallerType, OwnedGame
|
||||||
|
|
||||||
|
GOG_API = "https://api.gog.com"
|
||||||
|
GOG_EMBED = "https://embed.gog.com"
|
||||||
|
|
||||||
|
|
||||||
|
class GogApi:
|
||||||
|
"""Handles communication with the GOG API."""
|
||||||
|
|
||||||
|
def __init__(self, auth: AuthManager) -> None:
|
||||||
|
self.auth = auth
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({"User-Agent": "GOGUpdater/0.1"})
|
||||||
|
|
||||||
|
def _ensure_auth(self) -> bool:
|
||||||
|
token = self.auth.access_token
|
||||||
|
if not token:
|
||||||
|
logger.error("Not authenticated")
|
||||||
|
return False
|
||||||
|
self.session.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_owned_game_ids(self) -> list[int]:
|
||||||
|
"""Return list of owned game IDs."""
|
||||||
|
if not self._ensure_auth():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
response = self.session.get(f"{GOG_EMBED}/user/data/games", timeout=15)
|
||||||
|
except (requests.ConnectionError, requests.Timeout):
|
||||||
|
logger.error("Failed to fetch owned games — network error")
|
||||||
|
return []
|
||||||
|
if not response.ok:
|
||||||
|
logger.error(f"Failed to fetch owned games — HTTP {response.status_code}")
|
||||||
|
return []
|
||||||
|
return response.json().get("owned", [])
|
||||||
|
|
||||||
|
def get_game_details(self, game_id: int | str) -> dict | None:
|
||||||
|
"""Fetch game details including download links."""
|
||||||
|
if not self._ensure_auth():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = self.session.get(
|
||||||
|
f"{GOG_EMBED}/account/gameDetails/{game_id}.json",
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
except (requests.ConnectionError, requests.Timeout):
|
||||||
|
logger.error(f"Failed to fetch game details for {game_id} — network error")
|
||||||
|
return None
|
||||||
|
if not response.ok:
|
||||||
|
logger.error(f"Failed to fetch game details for {game_id} — HTTP {response.status_code}")
|
||||||
|
return None
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def get_product_info(self, game_id: int | str) -> dict | None:
|
||||||
|
"""Fetch product info with downloads and DLC expansions."""
|
||||||
|
if not self._ensure_auth():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = self.session.get(
|
||||||
|
f"{GOG_API}/products/{game_id}",
|
||||||
|
params={"expand": "downloads,expanded_dlcs"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
except (requests.ConnectionError, requests.Timeout):
|
||||||
|
logger.error(f"Failed to fetch product info for {game_id} — network error")
|
||||||
|
return None
|
||||||
|
if not response.ok:
|
||||||
|
logger.error(f"Failed to fetch product info for {game_id} — HTTP {response.status_code}")
|
||||||
|
return None
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def get_owned_games(self) -> list[OwnedGame]:
|
||||||
|
"""Fetch list of owned games with titles."""
|
||||||
|
game_ids = self.get_owned_game_ids()
|
||||||
|
if not game_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
games: list[OwnedGame] = []
|
||||||
|
for gid in game_ids:
|
||||||
|
info = self.get_product_info(gid)
|
||||||
|
if info:
|
||||||
|
games.append(OwnedGame(game_id=str(gid), title=info.get("title", f"Game {gid}")))
|
||||||
|
else:
|
||||||
|
games.append(OwnedGame(game_id=str(gid), title=f"Game {gid}"))
|
||||||
|
return games
|
||||||
|
|
||||||
|
def get_installers(
|
||||||
|
self,
|
||||||
|
game_id: int | str,
|
||||||
|
platforms: list[InstallerPlatform] | None = None,
|
||||||
|
languages: list[str] | None = None,
|
||||||
|
) -> list[InstallerInfo]:
|
||||||
|
"""Fetch available installers for a game, filtered by platform and language.
|
||||||
|
|
||||||
|
Uses the /products/{id}?expand=downloads,expanded_dlcs endpoint.
|
||||||
|
Response format: downloads.installers is a flat list of dicts with
|
||||||
|
keys: id, name, os, language, version, total_size, files[].
|
||||||
|
"""
|
||||||
|
product = self.get_product_info(game_id)
|
||||||
|
if not product:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if platforms is None:
|
||||||
|
platforms = [InstallerPlatform.WINDOWS, InstallerPlatform.LINUX]
|
||||||
|
|
||||||
|
platform_keys = {p: p.value for p in platforms} # WINDOWS -> "windows", LINUX -> "linux"
|
||||||
|
|
||||||
|
result: list[InstallerInfo] = []
|
||||||
|
|
||||||
|
# Main game installers
|
||||||
|
installers = product.get("downloads", {}).get("installers", [])
|
||||||
|
result.extend(self._parse_installers(installers, str(game_id), platform_keys, languages, InstallerType.GAME))
|
||||||
|
|
||||||
|
# DLC installers (only for owned DLCs)
|
||||||
|
owned_ids = self.get_owned_ids_set()
|
||||||
|
for dlc in product.get("expanded_dlcs", []):
|
||||||
|
dlc_id = str(dlc.get("id", ""))
|
||||||
|
if dlc_id not in owned_ids:
|
||||||
|
continue
|
||||||
|
dlc_installers = dlc.get("downloads", {}).get("installers", [])
|
||||||
|
result.extend(self._parse_installers(dlc_installers, dlc_id, platform_keys, languages, InstallerType.DLC))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _parse_installers(
|
||||||
|
self,
|
||||||
|
installers: list[dict],
|
||||||
|
game_id: str,
|
||||||
|
platform_keys: dict[InstallerPlatform, str],
|
||||||
|
languages: list[str] | None,
|
||||||
|
installer_type: InstallerType,
|
||||||
|
) -> list[InstallerInfo]:
|
||||||
|
"""Parse installer entries from products API response.
|
||||||
|
|
||||||
|
Each installer dict has: id, name, os, language, version, total_size, files[{id, size, downlink}].
|
||||||
|
"""
|
||||||
|
result: list[InstallerInfo] = []
|
||||||
|
allowed_os = set(platform_keys.values())
|
||||||
|
|
||||||
|
for installer in installers:
|
||||||
|
os_name = installer.get("os", "")
|
||||||
|
if os_name not in allowed_os:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lang = installer.get("language", "en")
|
||||||
|
if languages and lang not in languages:
|
||||||
|
continue
|
||||||
|
|
||||||
|
platform = InstallerPlatform.WINDOWS if os_name == "windows" else InstallerPlatform.LINUX
|
||||||
|
version = installer.get("version") or ""
|
||||||
|
name = installer.get("name", "unknown")
|
||||||
|
|
||||||
|
for file_entry in installer.get("files", []):
|
||||||
|
downlink = file_entry.get("downlink", "")
|
||||||
|
file_id = file_entry.get("id", "")
|
||||||
|
size = file_entry.get("size", 0)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
InstallerInfo(
|
||||||
|
installer_id=f"{game_id}_{os_name}_{lang}_{file_id}",
|
||||||
|
filename=f"{name}_{file_id}",
|
||||||
|
size=size,
|
||||||
|
version=version,
|
||||||
|
language=lang,
|
||||||
|
platform=platform,
|
||||||
|
installer_type=installer_type,
|
||||||
|
download_url=downlink,
|
||||||
|
game_id=game_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_bonus_content(self, game_id: int | str, product: dict | None = None) -> list[BonusContent]:
|
||||||
|
"""Fetch bonus content (soundtracks, wallpapers, manuals, etc.) for a game."""
|
||||||
|
if product is None:
|
||||||
|
product = self.get_product_info(game_id)
|
||||||
|
if not product:
|
||||||
|
return []
|
||||||
|
|
||||||
|
bonus_items = product.get("downloads", {}).get("bonus_content", [])
|
||||||
|
result: list[BonusContent] = []
|
||||||
|
|
||||||
|
for item in bonus_items:
|
||||||
|
name = item.get("name", "unknown")
|
||||||
|
bonus_type = item.get("type", "")
|
||||||
|
total_size = item.get("total_size", 0)
|
||||||
|
|
||||||
|
for file_entry in item.get("files", []):
|
||||||
|
downlink = file_entry.get("downlink", "")
|
||||||
|
file_id = str(file_entry.get("id", ""))
|
||||||
|
size = file_entry.get("size", total_size)
|
||||||
|
|
||||||
|
result.append(BonusContent(
|
||||||
|
bonus_id=f"{game_id}_bonus_{file_id}",
|
||||||
|
name=name,
|
||||||
|
bonus_type=bonus_type,
|
||||||
|
size=size,
|
||||||
|
download_url=downlink,
|
||||||
|
game_id=str(game_id),
|
||||||
|
))
|
||||||
|
|
||||||
|
logger.debug(f"Found {len(result)} bonus item(s) for game {game_id}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_owned_ids_set(self) -> set[str]:
|
||||||
|
"""Return a set of owned game/DLC IDs as strings."""
|
||||||
|
return {str(gid) for gid in self.get_owned_game_ids()}
|
||||||
|
|
||||||
|
def resolve_download_url(self, downlink: str) -> tuple[str, str] | None:
|
||||||
|
"""Resolve a GOG downlink to actual download URL and real filename.
|
||||||
|
|
||||||
|
GOG downlinks are two-level: first returns JSON with 'downlink' key,
|
||||||
|
second is the actual CDN URL with the real filename in the path.
|
||||||
|
|
||||||
|
Returns (url, filename) or None on failure.
|
||||||
|
"""
|
||||||
|
if not self._ensure_auth():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 1: Get CDN URL from API downlink
|
||||||
|
try:
|
||||||
|
response = self.session.get(downlink, timeout=15)
|
||||||
|
except (requests.ConnectionError, requests.Timeout):
|
||||||
|
logger.error("Failed to resolve download URL — network error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
logger.error(f"Failed to resolve download URL — HTTP {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
cdn_url = data.get("downlink", "")
|
||||||
|
if not cdn_url:
|
||||||
|
logger.error("No downlink in API response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract filename from CDN URL path
|
||||||
|
path = urlparse(cdn_url).path
|
||||||
|
filename = unquote(path.rsplit("/", 1)[-1])
|
||||||
|
|
||||||
|
return cdn_url, filename
|
||||||
129
src/auth.py
Normal file
129
src/auth.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""GOG OAuth2 authentication manager."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
GOG_AUTH_URL = "https://auth.gog.com"
|
||||||
|
CLIENT_ID = "46899977096215655"
|
||||||
|
CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"
|
||||||
|
REDIRECT_URI = "https://embed.gog.com/on_login_success?origin=client"
|
||||||
|
|
||||||
|
LOGIN_URL = (
|
||||||
|
f"https://login.gog.com/auth?client_id={CLIENT_ID}"
|
||||||
|
f"&redirect_uri={REDIRECT_URI}"
|
||||||
|
f"&response_type=code&layout=popup"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthManager:
|
||||||
|
"""Manages GOG OAuth2 tokens — exchange, refresh, persistence."""
|
||||||
|
|
||||||
|
def __init__(self, config_dir: Path) -> None:
|
||||||
|
self.config_dir = config_dir
|
||||||
|
self.auth_file = config_dir / "auth.json"
|
||||||
|
self.credentials: dict = {}
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({"User-Agent": "GOGUpdater/0.1"})
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
if self.auth_file.exists():
|
||||||
|
try:
|
||||||
|
self.credentials = json.loads(self.auth_file.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
logger.warning("Failed to read auth.json, starting fresh")
|
||||||
|
self.credentials = {}
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.auth_file.write_text(json.dumps(self.credentials, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_logged_in(self) -> bool:
|
||||||
|
return bool(self.credentials.get("access_token"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
if not self.is_logged_in:
|
||||||
|
return True
|
||||||
|
login_time = self.credentials.get("login_time", 0)
|
||||||
|
expires_in = self.credentials.get("expires_in", 0)
|
||||||
|
return time.time() >= login_time + expires_in
|
||||||
|
|
||||||
|
@property
|
||||||
|
def access_token(self) -> str | None:
|
||||||
|
if not self.is_logged_in:
|
||||||
|
return None
|
||||||
|
if self.is_expired:
|
||||||
|
if not self.refresh():
|
||||||
|
return None
|
||||||
|
return self.credentials.get("access_token")
|
||||||
|
|
||||||
|
def exchange_code(self, code: str) -> bool:
|
||||||
|
"""Exchange authorization code for tokens."""
|
||||||
|
url = (
|
||||||
|
f"{GOG_AUTH_URL}/token"
|
||||||
|
f"?client_id={CLIENT_ID}"
|
||||||
|
f"&client_secret={CLIENT_SECRET}"
|
||||||
|
f"&grant_type=authorization_code"
|
||||||
|
f"&redirect_uri={REDIRECT_URI}"
|
||||||
|
f"&code={code}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = self.session.get(url, timeout=15)
|
||||||
|
except (requests.ConnectionError, requests.Timeout):
|
||||||
|
logger.error("Failed to exchange authorization code — network error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
logger.error(f"Failed to exchange authorization code — HTTP {response.status_code}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
data["login_time"] = time.time()
|
||||||
|
self.credentials = data
|
||||||
|
self._save()
|
||||||
|
logger.info("Successfully authenticated with GOG")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def refresh(self) -> bool:
|
||||||
|
"""Refresh access token using refresh_token."""
|
||||||
|
refresh_token = self.credentials.get("refresh_token")
|
||||||
|
if not refresh_token:
|
||||||
|
logger.error("No refresh token available")
|
||||||
|
return False
|
||||||
|
|
||||||
|
url = (
|
||||||
|
f"{GOG_AUTH_URL}/token"
|
||||||
|
f"?client_id={CLIENT_ID}"
|
||||||
|
f"&client_secret={CLIENT_SECRET}"
|
||||||
|
f"&grant_type=refresh_token"
|
||||||
|
f"&refresh_token={refresh_token}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = self.session.get(url, timeout=15)
|
||||||
|
except (requests.ConnectionError, requests.Timeout):
|
||||||
|
logger.error("Failed to refresh token — network error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
logger.error(f"Failed to refresh token — HTTP {response.status_code}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
data["login_time"] = time.time()
|
||||||
|
self.credentials = data
|
||||||
|
self._save()
|
||||||
|
logger.info("Token refreshed successfully")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def logout(self) -> None:
|
||||||
|
"""Clear stored credentials."""
|
||||||
|
self.credentials = {}
|
||||||
|
if self.auth_file.exists():
|
||||||
|
self.auth_file.unlink()
|
||||||
|
logger.info("Logged out")
|
||||||
340
src/config.py
Normal file
340
src/config.py
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
"""Application configuration and installer metadata management."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.models import DownloadedInstaller, GameRecord, GameSettings, InstallerType, LANGUAGE_NAMES, language_folder_name
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "gogupdater"
|
||||||
|
METADATA_FILENAME = "gogupdater.json"
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfig:
|
||||||
|
"""Application settings stored in ~/.config/gogupdater/config.json."""
|
||||||
|
|
||||||
|
def __init__(self, config_dir: Path = DEFAULT_CONFIG_DIR) -> None:
|
||||||
|
self.config_dir = config_dir
|
||||||
|
self.config_file = config_dir / "config.json"
|
||||||
|
self.windows_path: str = ""
|
||||||
|
self.linux_path: str = ""
|
||||||
|
self.managed_game_ids: list[str] = []
|
||||||
|
self.languages: list[str] = ["en"]
|
||||||
|
self.english_only: bool = False
|
||||||
|
self.include_bonus: bool = False
|
||||||
|
self.game_settings: dict[str, GameSettings] = {}
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
if not self.config_file.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(self.config_file.read_text(encoding="utf-8"))
|
||||||
|
self.windows_path = data.get("windows_path", "")
|
||||||
|
self.linux_path = data.get("linux_path", "")
|
||||||
|
self.managed_game_ids = data.get("managed_game_ids", [])
|
||||||
|
self.languages = data.get("languages", ["en"])
|
||||||
|
self.english_only = data.get("english_only", False)
|
||||||
|
self.include_bonus = data.get("include_bonus", False)
|
||||||
|
self.game_settings = {
|
||||||
|
gid: GameSettings.from_dict(gs)
|
||||||
|
for gid, gs in data.get("game_settings", {}).items()
|
||||||
|
}
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
logger.warning("Failed to read config.json, using defaults")
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = {
|
||||||
|
"windows_path": self.windows_path,
|
||||||
|
"linux_path": self.linux_path,
|
||||||
|
"managed_game_ids": self.managed_game_ids,
|
||||||
|
"languages": self.languages,
|
||||||
|
"english_only": self.english_only,
|
||||||
|
"include_bonus": self.include_bonus,
|
||||||
|
"game_settings": {
|
||||||
|
gid: gs.to_dict()
|
||||||
|
for gid, gs in self.game_settings.items()
|
||||||
|
if not gs.is_default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.config_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
def is_game_managed(self, game_id: str) -> bool:
|
||||||
|
return game_id in self.managed_game_ids
|
||||||
|
|
||||||
|
def get_effective_languages(self, game_id: str | None = None) -> list[str]:
|
||||||
|
"""Return effective language list, checking per-game overrides first."""
|
||||||
|
english_only = self.english_only
|
||||||
|
languages = self.languages
|
||||||
|
|
||||||
|
if game_id and game_id in self.game_settings:
|
||||||
|
gs = self.game_settings[game_id]
|
||||||
|
if gs.english_only is not None:
|
||||||
|
english_only = gs.english_only
|
||||||
|
if gs.languages is not None:
|
||||||
|
languages = gs.languages
|
||||||
|
|
||||||
|
if english_only:
|
||||||
|
return ["en"]
|
||||||
|
return languages
|
||||||
|
|
||||||
|
def get_effective_include_bonus(self, game_id: str | None = None) -> bool:
|
||||||
|
"""Return include_bonus setting, checking per-game overrides first."""
|
||||||
|
if game_id and game_id in self.game_settings:
|
||||||
|
gs = self.game_settings[game_id]
|
||||||
|
if gs.include_bonus is not None:
|
||||||
|
return gs.include_bonus
|
||||||
|
return self.include_bonus
|
||||||
|
|
||||||
|
def get_game_settings(self, game_id: str) -> GameSettings:
|
||||||
|
"""Return per-game settings (may be all defaults)."""
|
||||||
|
return self.game_settings.get(game_id, GameSettings())
|
||||||
|
|
||||||
|
def set_game_settings(self, game_id: str, settings: GameSettings) -> None:
|
||||||
|
"""Save per-game settings. Removes entry if all defaults."""
|
||||||
|
if settings.is_default():
|
||||||
|
self.game_settings.pop(game_id, None)
|
||||||
|
else:
|
||||||
|
self.game_settings[game_id] = settings
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def set_game_managed(self, game_id: str, managed: bool) -> None:
|
||||||
|
if managed and game_id not in self.managed_game_ids:
|
||||||
|
self.managed_game_ids.append(game_id)
|
||||||
|
elif not managed and game_id in self.managed_game_ids:
|
||||||
|
self.managed_game_ids.remove(game_id)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataStore:
|
||||||
|
"""Manages gogupdater.json metadata file in installer directories."""
|
||||||
|
|
||||||
|
def __init__(self, base_path: str) -> None:
|
||||||
|
self.base_path = Path(base_path)
|
||||||
|
self.metadata_file = self.base_path / METADATA_FILENAME
|
||||||
|
self.games: dict[str, GameRecord] = {}
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
if not self.metadata_file.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(self.metadata_file.read_text(encoding="utf-8"))
|
||||||
|
for game_id, game_data in data.get("games", {}).items():
|
||||||
|
self.games[game_id] = GameRecord.from_dict(game_id, game_data)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
logger.warning(f"Failed to read {self.metadata_file}, starting fresh")
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
self.base_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = {"games": {gid: record.to_dict() for gid, record in self.games.items()}}
|
||||||
|
self.metadata_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
def get_game(self, game_id: str) -> GameRecord | None:
|
||||||
|
return self.games.get(game_id)
|
||||||
|
|
||||||
|
def update_game(self, record: GameRecord) -> None:
|
||||||
|
self.games[record.game_id] = record
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def get_downloaded_versions(self, game_id: str) -> list[str]:
|
||||||
|
"""Return list of downloaded versions for a game."""
|
||||||
|
record = self.games.get(game_id)
|
||||||
|
if not record:
|
||||||
|
return []
|
||||||
|
return list({i.version for i in record.installers})
|
||||||
|
|
||||||
|
def prune_old_versions(self, game_id: str, keep_latest: int = 1) -> list[Path]:
|
||||||
|
"""Remove old version directories, keeping the N most recent. Returns deleted paths."""
|
||||||
|
record = self.games.get(game_id)
|
||||||
|
if not record:
|
||||||
|
return []
|
||||||
|
|
||||||
|
versions = sorted({i.version for i in record.installers})
|
||||||
|
if len(versions) <= keep_latest:
|
||||||
|
return []
|
||||||
|
|
||||||
|
versions_to_remove = versions[:-keep_latest]
|
||||||
|
deleted: list[Path] = []
|
||||||
|
|
||||||
|
for version in versions_to_remove:
|
||||||
|
version_dir = self.base_path / record.name / version
|
||||||
|
if version_dir.exists():
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(version_dir)
|
||||||
|
deleted.append(version_dir)
|
||||||
|
logger.info(f"Pruned {version_dir}")
|
||||||
|
|
||||||
|
record.installers = [i for i in record.installers if i.version not in versions_to_remove]
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def verify_metadata(self) -> int:
|
||||||
|
"""Verify that recorded files still exist on disk.
|
||||||
|
|
||||||
|
Removes metadata entries for missing files. If a game has no
|
||||||
|
remaining installers or bonuses, removes the entire game record.
|
||||||
|
Returns number of stale entries removed.
|
||||||
|
"""
|
||||||
|
if not self.base_path.is_dir():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
removed = 0
|
||||||
|
game_ids_to_delete: list[str] = []
|
||||||
|
|
||||||
|
for game_id, record in self.games.items():
|
||||||
|
game_dir = self.base_path / record.name
|
||||||
|
|
||||||
|
# If the entire game folder is gone, remove the record
|
||||||
|
if not game_dir.exists():
|
||||||
|
game_ids_to_delete.append(game_id)
|
||||||
|
removed += len(record.installers) + len(record.bonuses)
|
||||||
|
logger.info(f"Verify: game folder missing for '{record.name}', removing record")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check individual installer files
|
||||||
|
valid_installers: list[DownloadedInstaller] = []
|
||||||
|
for inst in record.installers:
|
||||||
|
# Try with language subfolder first, then without
|
||||||
|
lang_path = game_dir / inst.version / language_folder_name(inst.language) / inst.filename
|
||||||
|
direct_path = game_dir / inst.version / inst.filename
|
||||||
|
|
||||||
|
if lang_path.exists() or direct_path.exists():
|
||||||
|
valid_installers.append(inst)
|
||||||
|
else:
|
||||||
|
removed += 1
|
||||||
|
logger.info(f"Verify: missing installer '{inst.filename}' for '{record.name}'")
|
||||||
|
|
||||||
|
# Check bonus files
|
||||||
|
valid_bonuses = []
|
||||||
|
for bonus in record.bonuses:
|
||||||
|
bonus_path = game_dir / "Bonus" / bonus.filename
|
||||||
|
if bonus_path.exists():
|
||||||
|
valid_bonuses.append(bonus)
|
||||||
|
else:
|
||||||
|
removed += 1
|
||||||
|
logger.info(f"Verify: missing bonus '{bonus.filename}' for '{record.name}'")
|
||||||
|
|
||||||
|
record.installers = valid_installers
|
||||||
|
record.bonuses = valid_bonuses
|
||||||
|
|
||||||
|
# Update latest_version based on remaining installers
|
||||||
|
if valid_installers:
|
||||||
|
versions = sorted({i.version for i in valid_installers})
|
||||||
|
record.latest_version = versions[-1]
|
||||||
|
else:
|
||||||
|
record.latest_version = ""
|
||||||
|
|
||||||
|
# If nothing remains, mark for deletion
|
||||||
|
if not valid_installers and not valid_bonuses:
|
||||||
|
game_ids_to_delete.append(game_id)
|
||||||
|
|
||||||
|
for game_id in game_ids_to_delete:
|
||||||
|
del self.games[game_id]
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
self.save()
|
||||||
|
logger.info(f"Verify: removed {removed} stale metadata entries")
|
||||||
|
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def scan_existing_installers(self, title_to_id: dict[str, str]) -> int:
|
||||||
|
"""Scan directory for existing installers and populate metadata.
|
||||||
|
|
||||||
|
Expects structure: base_path/GameName/version/[Language/]installer_file
|
||||||
|
title_to_id maps game titles (folder names) to GOG game IDs.
|
||||||
|
Returns number of games detected.
|
||||||
|
"""
|
||||||
|
if not self.base_path.is_dir():
|
||||||
|
logger.warning(f"Scan path does not exist: {self.base_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Reverse language map: folder name -> code
|
||||||
|
lang_name_to_code: dict[str, str] = {v: k for k, v in LANGUAGE_NAMES.items()}
|
||||||
|
|
||||||
|
detected = 0
|
||||||
|
for game_dir in sorted(self.base_path.iterdir()):
|
||||||
|
if not game_dir.is_dir() or game_dir.name.startswith("."):
|
||||||
|
continue
|
||||||
|
if game_dir.name == METADATA_FILENAME:
|
||||||
|
continue
|
||||||
|
|
||||||
|
game_name = game_dir.name
|
||||||
|
game_id = title_to_id.get(game_name)
|
||||||
|
if not game_id:
|
||||||
|
logger.debug(f"Scan: no matching game ID for folder '{game_name}', skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Already tracked — skip
|
||||||
|
if game_id in self.games:
|
||||||
|
logger.debug(f"Scan: '{game_name}' already in metadata, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
installers: list[DownloadedInstaller] = []
|
||||||
|
latest_version = ""
|
||||||
|
|
||||||
|
for version_dir in sorted(game_dir.iterdir()):
|
||||||
|
if not version_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
version = version_dir.name
|
||||||
|
if not latest_version:
|
||||||
|
latest_version = version
|
||||||
|
|
||||||
|
# Check if subdirs are language folders or direct installer files
|
||||||
|
sub_entries = list(version_dir.iterdir())
|
||||||
|
has_lang_subdirs = any(
|
||||||
|
e.is_dir() and e.name in lang_name_to_code for e in sub_entries
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_lang_subdirs:
|
||||||
|
for lang_dir in version_dir.iterdir():
|
||||||
|
if not lang_dir.is_dir():
|
||||||
|
continue
|
||||||
|
lang_code = lang_name_to_code.get(lang_dir.name, lang_dir.name)
|
||||||
|
for f in lang_dir.iterdir():
|
||||||
|
if f.is_file() and not f.name.startswith("."):
|
||||||
|
installers.append(DownloadedInstaller(
|
||||||
|
filename=f.name,
|
||||||
|
size=f.stat().st_size,
|
||||||
|
version=version,
|
||||||
|
language=lang_code,
|
||||||
|
installer_type=InstallerType.GAME,
|
||||||
|
downloaded_at="",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# No language subfolder — files directly in version dir
|
||||||
|
for f in version_dir.iterdir():
|
||||||
|
if f.is_file() and not f.name.startswith("."):
|
||||||
|
installers.append(DownloadedInstaller(
|
||||||
|
filename=f.name,
|
||||||
|
size=f.stat().st_size,
|
||||||
|
version=version,
|
||||||
|
language="en",
|
||||||
|
installer_type=InstallerType.GAME,
|
||||||
|
downloaded_at="",
|
||||||
|
))
|
||||||
|
|
||||||
|
if installers:
|
||||||
|
# Latest version = last sorted version directory
|
||||||
|
all_versions = sorted({i.version for i in installers})
|
||||||
|
latest_version = all_versions[-1] if all_versions else ""
|
||||||
|
|
||||||
|
record = GameRecord(
|
||||||
|
game_id=game_id,
|
||||||
|
name=game_name,
|
||||||
|
latest_version=latest_version,
|
||||||
|
managed=True,
|
||||||
|
installers=installers,
|
||||||
|
)
|
||||||
|
self.games[game_id] = record
|
||||||
|
detected += 1
|
||||||
|
logger.info(f"Scan: detected '{game_name}' with {len(installers)} installer(s), version {latest_version}")
|
||||||
|
|
||||||
|
if detected:
|
||||||
|
self.save()
|
||||||
|
return detected
|
||||||
80
src/constants.py
Normal file
80
src/constants.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Generic application constants template.
|
||||||
|
|
||||||
|
Usage in your project:
|
||||||
|
1. Copy this file to src/constants.py
|
||||||
|
2. Fill in APP_NAME and APP_FULL_NAME
|
||||||
|
3. Import VERSION, APP_TITLE, DEFAULT_DEBUG where needed
|
||||||
|
|
||||||
|
Version loading priority:
|
||||||
|
1. pyproject.toml [project] version (preferred)
|
||||||
|
2. src/_version.py __version__ (generated fallback for frozen builds)
|
||||||
|
3. "0.0.0" (last resort)
|
||||||
|
|
||||||
|
Debug mode:
|
||||||
|
Controlled exclusively via .env: ENV_DEBUG=true
|
||||||
|
Accepted true-values: true, 1, yes (case-insensitive)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Version
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).parent.parent
|
||||||
|
_PYPROJECT = _ROOT / "pyproject.toml"
|
||||||
|
_VERSION_FILE = Path(__file__).parent / "_version.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_version() -> str:
|
||||||
|
# 1. pyproject.toml
|
||||||
|
try:
|
||||||
|
with open(_PYPROJECT, "rb") as f:
|
||||||
|
version = tomllib.load(f)["project"]["version"]
|
||||||
|
# Write fallback for frozen/PyInstaller builds
|
||||||
|
_VERSION_FILE.write_text(
|
||||||
|
f'"""Auto-generated — do not edit manually."""\n__version__ = "{version}"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
except (FileNotFoundError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. _version.py
|
||||||
|
try:
|
||||||
|
from src._version import __version__ # type: ignore[import]
|
||||||
|
return __version__
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. last resort
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Debug mode
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_debug() -> bool:
|
||||||
|
return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public constants ← fill in APP_NAME / APP_FULL_NAME for each project
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
APP_NAME: str = "GOGUpdater"
|
||||||
|
APP_FULL_NAME: str = "GOG Updater"
|
||||||
|
|
||||||
|
_VERSION_NUMBER: str = _load_version()
|
||||||
|
DEFAULT_DEBUG: bool = _load_debug()
|
||||||
|
|
||||||
|
VERSION: str = f"v{_VERSION_NUMBER}" + ("DEV" if DEFAULT_DEBUG else "")
|
||||||
|
APP_TITLE: str = f"{APP_FULL_NAME} {VERSION}"
|
||||||
284
src/downloader.py
Normal file
284
src/downloader.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"""Installer file downloader with progress reporting and resume support."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.api import GogApi
|
||||||
|
from src.config import MetadataStore
|
||||||
|
from src.models import (
|
||||||
|
BonusContent,
|
||||||
|
DownloadedBonus,
|
||||||
|
DownloadedInstaller,
|
||||||
|
GameRecord,
|
||||||
|
InstallerInfo,
|
||||||
|
language_folder_name,
|
||||||
|
sanitize_folder_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadProgressCallback(Protocol):
|
||||||
|
"""Protocol for progress reporting during downloads."""
|
||||||
|
|
||||||
|
def on_progress(self, downloaded: int, total: int, speed: float) -> None: ...
|
||||||
|
|
||||||
|
def on_finished(self, success: bool, message: str) -> None: ...
|
||||||
|
|
||||||
|
def is_cancelled(self) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InstallerDownloader:
|
||||||
|
"""Downloads installer files from GOG with resume support."""
|
||||||
|
|
||||||
|
CHUNK_SIZE = 1024 * 1024 # 1 MiB
|
||||||
|
|
||||||
|
def __init__(self, api: GogApi, metadata: MetadataStore) -> None:
|
||||||
|
self.api = api
|
||||||
|
self.metadata = metadata
|
||||||
|
|
||||||
|
def download_installer(
|
||||||
|
self,
|
||||||
|
installer: InstallerInfo,
|
||||||
|
game_name: str,
|
||||||
|
single_language: bool = False,
|
||||||
|
callback: DownloadProgressCallback | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Download a single installer file to the correct directory structure.
|
||||||
|
|
||||||
|
If single_language is True, the language subfolder is skipped
|
||||||
|
(game only available in one language).
|
||||||
|
"""
|
||||||
|
game_name = sanitize_folder_name(game_name)
|
||||||
|
# Resolve actual download URL and real filename
|
||||||
|
resolved = self.api.resolve_download_url(installer.download_url)
|
||||||
|
if not resolved:
|
||||||
|
logger.error(f"Could not resolve download URL for {installer.filename}")
|
||||||
|
if callback:
|
||||||
|
callback.on_finished(False, "Failed to resolve download URL")
|
||||||
|
return False
|
||||||
|
|
||||||
|
actual_url, real_filename = resolved
|
||||||
|
|
||||||
|
version = installer.version if installer.version else "unknown"
|
||||||
|
if single_language:
|
||||||
|
target_dir = self.metadata.base_path / game_name / version
|
||||||
|
else:
|
||||||
|
lang_folder = language_folder_name(installer.language)
|
||||||
|
target_dir = self.metadata.base_path / game_name / version / lang_folder
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_file = target_dir / real_filename
|
||||||
|
|
||||||
|
# Check for partial download (resume)
|
||||||
|
existing_size = target_file.stat().st_size if target_file.exists() else 0
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if existing_size > 0:
|
||||||
|
headers["Range"] = f"bytes={existing_size}-"
|
||||||
|
logger.info(f"Resuming download of {real_filename} from {existing_size} bytes")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(actual_url, headers=headers, stream=True, timeout=30)
|
||||||
|
except (requests.ConnectionError, requests.Timeout) as e:
|
||||||
|
logger.error(f"Download failed — {e}")
|
||||||
|
if callback:
|
||||||
|
callback.on_finished(False, f"Network error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if response.status_code == 416:
|
||||||
|
logger.info(f"Already fully downloaded: {real_filename}")
|
||||||
|
self._record_download(installer, game_name, real_filename)
|
||||||
|
if callback:
|
||||||
|
callback.on_finished(True, "Already downloaded")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
logger.error(f"Download failed — HTTP {response.status_code}")
|
||||||
|
if callback:
|
||||||
|
callback.on_finished(False, f"HTTP {response.status_code}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
total_size = int(response.headers.get("content-length", 0)) + existing_size
|
||||||
|
downloaded = existing_size
|
||||||
|
mode = "ab" if existing_size > 0 else "wb"
|
||||||
|
|
||||||
|
last_time = time.monotonic()
|
||||||
|
last_downloaded = downloaded
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(target_file, mode) as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=self.CHUNK_SIZE):
|
||||||
|
if callback and callback.is_cancelled():
|
||||||
|
logger.info("Download cancelled by user")
|
||||||
|
callback.on_finished(False, "Cancelled")
|
||||||
|
return False
|
||||||
|
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
|
||||||
|
if callback:
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - last_time
|
||||||
|
if elapsed >= 0.5:
|
||||||
|
speed = (downloaded - last_downloaded) / elapsed
|
||||||
|
last_time = now
|
||||||
|
last_downloaded = downloaded
|
||||||
|
callback.on_progress(downloaded, total_size, speed)
|
||||||
|
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"Write error: {e}")
|
||||||
|
if callback:
|
||||||
|
callback.on_finished(False, f"Write error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._record_download(installer, game_name, real_filename)
|
||||||
|
logger.info(f"Downloaded {real_filename} ({downloaded} bytes)")
|
||||||
|
if callback:
|
||||||
|
callback.on_finished(True, "Download complete")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def download_bonus(
|
||||||
|
self,
|
||||||
|
bonus: BonusContent,
|
||||||
|
game_name: str,
|
||||||
|
callback: DownloadProgressCallback | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Download a bonus content file to GameName/Bonus/ directory."""
|
||||||
|
game_name = sanitize_folder_name(game_name)
|
||||||
|
resolved = self.api.resolve_download_url(bonus.download_url)
|
||||||
|
if not resolved:
|
||||||
|
logger.error(f"Could not resolve download URL for bonus '{bonus.name}'")
|
||||||
|
if callback:
|
||||||
|
callback.on_finished(False, "Failed to resolve download URL")
|
||||||
|
return False
|
||||||
|
|
||||||
|
actual_url, real_filename = resolved
|
||||||
|
|
||||||
|
target_dir = self.metadata.base_path / game_name / "Bonus"
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_file = target_dir / real_filename
|
||||||
|
|
||||||
|
# Skip if already downloaded with same size
|
||||||
|
if target_file.exists() and target_file.stat().st_size == bonus.size:
|
||||||
|
logger.info(f"Bonus already downloaded: {real_filename}")
|
||||||
|
self._record_bonus(bonus, game_name, real_filename)
|
||||||
|
if callback:
|
||||||
|
callback.on_finished(True, "Already downloaded")
|
||||||
|
return True
|
||||||
|
|
||||||
|
existing_size = target_file.stat().st_size if target_file.exists() else 0
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if existing_size > 0:
|
||||||
|
headers["Range"] = f"bytes={existing_size}-"
|
||||||
|
logger.info(f"Resuming bonus download of {real_filename} from {existing_size} bytes")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(actual_url, headers=headers, stream=True, timeout=30)
|
||||||
|
except (requests.ConnectionError, requests.Timeout) as e:
|
||||||
|
logger.error(f"Bonus download failed — {e}")
|
||||||
|
if callback:
|
||||||
|
callback.on_finished(False, f"Network error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if response.status_code == 416:
|
||||||
|
logger.info(f"Bonus already fully downloaded: {real_filename}")
|
||||||
|
self._record_bonus(bonus, game_name, real_filename)
|
||||||
|
if callback:
|
||||||
|
callback.on_finished(True, "Already downloaded")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
logger.error(f"Bonus download failed — HTTP {response.status_code}")
|
||||||
|
if callback:
|
||||||
|
callback.on_finished(False, f"HTTP {response.status_code}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
total_size = int(response.headers.get("content-length", 0)) + existing_size
|
||||||
|
downloaded = existing_size
|
||||||
|
mode = "ab" if existing_size > 0 else "wb"
|
||||||
|
|
||||||
|
last_time = time.monotonic()
|
||||||
|
last_downloaded = downloaded
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(target_file, mode) as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=self.CHUNK_SIZE):
|
||||||
|
if callback and callback.is_cancelled():
|
||||||
|
logger.info("Bonus download cancelled by user")
|
||||||
|
callback.on_finished(False, "Cancelled")
|
||||||
|
return False
|
||||||
|
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
|
||||||
|
if callback:
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - last_time
|
||||||
|
if elapsed >= 0.5:
|
||||||
|
speed = (downloaded - last_downloaded) / elapsed
|
||||||
|
last_time = now
|
||||||
|
last_downloaded = downloaded
|
||||||
|
callback.on_progress(downloaded, total_size, speed)
|
||||||
|
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"Bonus write error: {e}")
|
||||||
|
if callback:
|
||||||
|
callback.on_finished(False, f"Write error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._record_bonus(bonus, game_name, real_filename)
|
||||||
|
logger.info(f"Downloaded bonus {real_filename} ({downloaded} bytes)")
|
||||||
|
if callback:
|
||||||
|
callback.on_finished(True, "Download complete")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _record_bonus(self, bonus: BonusContent, game_name: str, real_filename: str) -> None:
|
||||||
|
"""Record the bonus download in metadata store."""
|
||||||
|
game_name = sanitize_folder_name(game_name)
|
||||||
|
record = self.metadata.get_game(bonus.game_id)
|
||||||
|
if not record:
|
||||||
|
record = GameRecord(
|
||||||
|
game_id=bonus.game_id,
|
||||||
|
name=game_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
downloaded = DownloadedBonus(
|
||||||
|
filename=real_filename,
|
||||||
|
name=bonus.name,
|
||||||
|
bonus_type=bonus.bonus_type,
|
||||||
|
size=bonus.size,
|
||||||
|
downloaded_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
record.bonuses = [b for b in record.bonuses if b.filename != real_filename]
|
||||||
|
record.bonuses.append(downloaded)
|
||||||
|
record.last_checked = datetime.now(timezone.utc).isoformat()
|
||||||
|
self.metadata.update_game(record)
|
||||||
|
|
||||||
|
def _record_download(self, installer: InstallerInfo, game_name: str, real_filename: str) -> None:
|
||||||
|
"""Record the download in metadata store."""
|
||||||
|
game_name = sanitize_folder_name(game_name)
|
||||||
|
record = self.metadata.get_game(installer.game_id)
|
||||||
|
if not record:
|
||||||
|
record = GameRecord(
|
||||||
|
game_id=installer.game_id,
|
||||||
|
name=game_name,
|
||||||
|
latest_version=installer.version,
|
||||||
|
)
|
||||||
|
|
||||||
|
downloaded = DownloadedInstaller(
|
||||||
|
filename=real_filename,
|
||||||
|
size=installer.size,
|
||||||
|
version=installer.version,
|
||||||
|
language=installer.language,
|
||||||
|
installer_type=installer.installer_type,
|
||||||
|
downloaded_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace existing entry for same file, or add new
|
||||||
|
record.installers = [i for i in record.installers if i.filename != real_filename]
|
||||||
|
record.installers.append(downloaded)
|
||||||
|
record.latest_version = installer.version
|
||||||
|
record.last_checked = datetime.now(timezone.utc).isoformat()
|
||||||
|
self.metadata.update_game(record)
|
||||||
338
src/models.py
Normal file
338
src/models.py
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
"""Data models for GOGUpdater."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
LANGUAGE_NAMES: dict[str, str] = {
|
||||||
|
"en": "English",
|
||||||
|
"fr": "French",
|
||||||
|
"de": "German",
|
||||||
|
"es": "Spanish",
|
||||||
|
"it": "Italian",
|
||||||
|
"pt-BR": "Portuguese (Brazil)",
|
||||||
|
"ru": "Russian",
|
||||||
|
"pl": "Polish",
|
||||||
|
"nl": "Dutch",
|
||||||
|
"cs": "Czech",
|
||||||
|
"sk": "Slovak",
|
||||||
|
"hu": "Hungarian",
|
||||||
|
"tr": "Turkish",
|
||||||
|
"ja": "Japanese",
|
||||||
|
"ko": "Korean",
|
||||||
|
"zh-Hans": "Chinese (Simplified)",
|
||||||
|
"zh-Hant": "Chinese (Traditional)",
|
||||||
|
"ro": "Romanian",
|
||||||
|
"da": "Danish",
|
||||||
|
"fi": "Finnish",
|
||||||
|
"sv": "Swedish",
|
||||||
|
"no": "Norwegian",
|
||||||
|
"ar": "Arabic",
|
||||||
|
"uk": "Ukrainian",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def language_folder_name(code: str) -> str:
|
||||||
|
"""Return human-readable language name for folder naming."""
|
||||||
|
return LANGUAGE_NAMES.get(code, code)
|
||||||
|
|
||||||
|
|
||||||
|
# Characters to strip entirely from folder names
|
||||||
|
_STRIP_CHARS_RE = re.compile(r"[®™©™\u2122\u00AE\u00A9]")
|
||||||
|
# Characters invalid in folder names on Windows/Linux
|
||||||
|
_INVALID_CHARS_RE = re.compile(r'[<>"/\\|?*]')
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_folder_name(name: str) -> str:
|
||||||
|
"""Convert a game title to a valid, unified folder name.
|
||||||
|
|
||||||
|
- Replaces ':' with ' - '
|
||||||
|
- Strips special characters (®, ™, ©)
|
||||||
|
- Removes other invalid filesystem characters
|
||||||
|
- Collapses multiple spaces/dashes
|
||||||
|
- Strips leading/trailing whitespace and dots
|
||||||
|
"""
|
||||||
|
result = name
|
||||||
|
# Colon -> " - "
|
||||||
|
result = result.replace(":", " - ")
|
||||||
|
# Strip special symbols
|
||||||
|
result = _STRIP_CHARS_RE.sub("", result)
|
||||||
|
# Remove filesystem-invalid characters
|
||||||
|
result = _INVALID_CHARS_RE.sub("", result)
|
||||||
|
# Collapse multiple spaces
|
||||||
|
result = re.sub(r"\s{2,}", " ", result)
|
||||||
|
# Collapse " - - " patterns from adjacent replacements
|
||||||
|
result = re.sub(r"(\s*-\s*){2,}", " - ", result)
|
||||||
|
# Strip leading/trailing whitespace and dots
|
||||||
|
result = result.strip().strip(".")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class InstallerType(Enum):
|
||||||
|
GAME = "game"
|
||||||
|
DLC = "dlc"
|
||||||
|
|
||||||
|
|
||||||
|
class InstallerPlatform(Enum):
|
||||||
|
WINDOWS = "windows"
|
||||||
|
LINUX = "linux"
|
||||||
|
|
||||||
|
|
||||||
|
class GameStatus(Enum):
|
||||||
|
UP_TO_DATE = "up_to_date"
|
||||||
|
UPDATE_AVAILABLE = "update_available"
|
||||||
|
NOT_DOWNLOADED = "not_downloaded"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
UNVERSIONED = "unversioned"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InstallerInfo:
|
||||||
|
"""Installer metadata from GOG API."""
|
||||||
|
|
||||||
|
installer_id: str
|
||||||
|
filename: str
|
||||||
|
size: int
|
||||||
|
version: str
|
||||||
|
language: str
|
||||||
|
platform: InstallerPlatform
|
||||||
|
installer_type: InstallerType
|
||||||
|
download_url: str
|
||||||
|
game_id: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"installer_id": self.installer_id,
|
||||||
|
"filename": self.filename,
|
||||||
|
"size": self.size,
|
||||||
|
"version": self.version,
|
||||||
|
"language": self.language,
|
||||||
|
"platform": self.platform.value,
|
||||||
|
"installer_type": self.installer_type.value,
|
||||||
|
"download_url": self.download_url,
|
||||||
|
"game_id": self.game_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "InstallerInfo":
|
||||||
|
return cls(
|
||||||
|
installer_id=data["installer_id"],
|
||||||
|
filename=data["filename"],
|
||||||
|
size=data["size"],
|
||||||
|
version=data["version"],
|
||||||
|
language=data["language"],
|
||||||
|
platform=InstallerPlatform(data["platform"]),
|
||||||
|
installer_type=InstallerType(data["installer_type"]),
|
||||||
|
download_url=data.get("download_url", ""),
|
||||||
|
game_id=data.get("game_id", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DownloadedInstaller:
|
||||||
|
"""Record of a downloaded installer file."""
|
||||||
|
|
||||||
|
filename: str
|
||||||
|
size: int
|
||||||
|
version: str
|
||||||
|
language: str
|
||||||
|
installer_type: InstallerType
|
||||||
|
downloaded_at: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"filename": self.filename,
|
||||||
|
"size": self.size,
|
||||||
|
"version": self.version,
|
||||||
|
"language": self.language,
|
||||||
|
"installer_type": self.installer_type.value,
|
||||||
|
"downloaded_at": self.downloaded_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "DownloadedInstaller":
|
||||||
|
return cls(
|
||||||
|
filename=data["filename"],
|
||||||
|
size=data["size"],
|
||||||
|
version=data["version"],
|
||||||
|
language=data["language"],
|
||||||
|
installer_type=InstallerType(data.get("installer_type", "game")),
|
||||||
|
downloaded_at=data.get("downloaded_at", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GameRecord:
|
||||||
|
"""Record of a managed game in metadata file."""
|
||||||
|
|
||||||
|
game_id: str
|
||||||
|
name: str
|
||||||
|
latest_version: str = ""
|
||||||
|
managed: bool = True
|
||||||
|
installers: list[DownloadedInstaller] = field(default_factory=list)
|
||||||
|
bonuses: list[DownloadedBonus] = field(default_factory=list)
|
||||||
|
last_checked: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"latest_version": self.latest_version,
|
||||||
|
"managed": self.managed,
|
||||||
|
"installers": [i.to_dict() for i in self.installers],
|
||||||
|
"bonuses": [b.to_dict() for b in self.bonuses],
|
||||||
|
"last_checked": self.last_checked,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, game_id: str, data: dict) -> "GameRecord":
|
||||||
|
return cls(
|
||||||
|
game_id=game_id,
|
||||||
|
name=data["name"],
|
||||||
|
latest_version=data.get("latest_version", ""),
|
||||||
|
managed=data.get("managed", True),
|
||||||
|
installers=[DownloadedInstaller.from_dict(i) for i in data.get("installers", [])],
|
||||||
|
bonuses=[DownloadedBonus.from_dict(b) for b in data.get("bonuses", [])],
|
||||||
|
last_checked=data.get("last_checked", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OwnedGame:
|
||||||
|
"""Game from GOG library."""
|
||||||
|
|
||||||
|
game_id: str
|
||||||
|
title: str
|
||||||
|
managed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BonusContent:
|
||||||
|
"""Bonus content item from GOG API (soundtrack, wallpaper, manual, etc.)."""
|
||||||
|
|
||||||
|
bonus_id: str
|
||||||
|
name: str
|
||||||
|
bonus_type: str
|
||||||
|
size: int
|
||||||
|
download_url: str
|
||||||
|
game_id: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"bonus_id": self.bonus_id,
|
||||||
|
"name": self.name,
|
||||||
|
"bonus_type": self.bonus_type,
|
||||||
|
"size": self.size,
|
||||||
|
"download_url": self.download_url,
|
||||||
|
"game_id": self.game_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "BonusContent":
|
||||||
|
return cls(
|
||||||
|
bonus_id=data["bonus_id"],
|
||||||
|
name=data["name"],
|
||||||
|
bonus_type=data.get("bonus_type", ""),
|
||||||
|
size=data.get("size", 0),
|
||||||
|
download_url=data.get("download_url", ""),
|
||||||
|
game_id=data.get("game_id", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DownloadedBonus:
|
||||||
|
"""Record of a downloaded bonus content file."""
|
||||||
|
|
||||||
|
filename: str
|
||||||
|
name: str
|
||||||
|
bonus_type: str
|
||||||
|
size: int
|
||||||
|
downloaded_at: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"filename": self.filename,
|
||||||
|
"name": self.name,
|
||||||
|
"bonus_type": self.bonus_type,
|
||||||
|
"size": self.size,
|
||||||
|
"downloaded_at": self.downloaded_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "DownloadedBonus":
|
||||||
|
return cls(
|
||||||
|
filename=data["filename"],
|
||||||
|
name=data["name"],
|
||||||
|
bonus_type=data.get("bonus_type", ""),
|
||||||
|
size=data.get("size", 0),
|
||||||
|
downloaded_at=data.get("downloaded_at", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GameSettings:
|
||||||
|
"""Per-game settings that override global defaults. None = use global."""
|
||||||
|
|
||||||
|
languages: list[str] | None = None
|
||||||
|
english_only: bool | None = None
|
||||||
|
include_bonus: bool | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
data: dict = {}
|
||||||
|
if self.languages is not None:
|
||||||
|
data["languages"] = self.languages
|
||||||
|
if self.english_only is not None:
|
||||||
|
data["english_only"] = self.english_only
|
||||||
|
if self.include_bonus is not None:
|
||||||
|
data["include_bonus"] = self.include_bonus
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "GameSettings":
|
||||||
|
return cls(
|
||||||
|
languages=data.get("languages"),
|
||||||
|
english_only=data.get("english_only"),
|
||||||
|
include_bonus=data.get("include_bonus"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_default(self) -> bool:
|
||||||
|
"""True if all values are None (no overrides)."""
|
||||||
|
return self.languages is None and self.english_only is None and self.include_bonus is None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LanguageVersionInfo:
|
||||||
|
"""Version info for a single language of a game."""
|
||||||
|
|
||||||
|
language: str
|
||||||
|
current_version: str
|
||||||
|
latest_version: str
|
||||||
|
status: GameStatus
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GameStatusInfo:
|
||||||
|
"""Status information for display in status tab."""
|
||||||
|
|
||||||
|
game_id: str
|
||||||
|
name: str
|
||||||
|
current_version: str
|
||||||
|
latest_version: str
|
||||||
|
status: GameStatus
|
||||||
|
language_versions: list[LanguageVersionInfo] = field(default_factory=list)
|
||||||
|
last_checked: str = ""
|
||||||
|
parent_name: str = ""
|
||||||
|
dlcs: list["GameStatusInfo"] = field(default_factory=list)
|
||||||
|
bonus_available: int = 0
|
||||||
|
bonus_downloaded: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_text(self) -> str:
|
||||||
|
labels = {
|
||||||
|
GameStatus.UP_TO_DATE: "Up to date",
|
||||||
|
GameStatus.UPDATE_AVAILABLE: "Update available",
|
||||||
|
GameStatus.NOT_DOWNLOADED: "Not downloaded",
|
||||||
|
GameStatus.UNKNOWN: "Unknown",
|
||||||
|
GameStatus.UNVERSIONED: "Cannot track version",
|
||||||
|
}
|
||||||
|
return labels[self.status]
|
||||||
0
src/ui/__init__.py
Normal file
0
src/ui/__init__.py
Normal file
128
src/ui/dialog_game_settings.py
Normal file
128
src/ui/dialog_game_settings.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Per-game settings dialog — override global language, bonus, and english_only settings."""
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QGroupBox,
|
||||||
|
QLabel,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.config import AppConfig
|
||||||
|
from src.models import GameSettings, LANGUAGE_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
class GameSettingsDialog(QDialog):
|
||||||
|
"""Dialog for configuring per-game settings (languages, bonus, english_only)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
game_id: str,
|
||||||
|
game_title: str,
|
||||||
|
config: AppConfig,
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.game_id = game_id
|
||||||
|
self.config = config
|
||||||
|
self.settings = config.get_game_settings(game_id)
|
||||||
|
|
||||||
|
self.setWindowTitle(f"Settings — {game_title}")
|
||||||
|
self.setMinimumWidth(400)
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self) -> None:
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
info = QLabel(
|
||||||
|
"Override global settings for this game.\n"
|
||||||
|
"Unchecked overrides use global defaults from Settings/Languages tabs."
|
||||||
|
)
|
||||||
|
info.setWordWrap(True)
|
||||||
|
layout.addWidget(info)
|
||||||
|
|
||||||
|
# English only override
|
||||||
|
self.english_only_group = QGroupBox("Override English only")
|
||||||
|
self.english_only_group.setCheckable(True)
|
||||||
|
self.english_only_group.setChecked(self.settings.english_only is not None)
|
||||||
|
eo_layout = QVBoxLayout(self.english_only_group)
|
||||||
|
self.english_only_cb = QCheckBox("English only")
|
||||||
|
self.english_only_cb.setChecked(
|
||||||
|
self.settings.english_only if self.settings.english_only is not None else self.config.english_only
|
||||||
|
)
|
||||||
|
eo_layout.addWidget(self.english_only_cb)
|
||||||
|
layout.addWidget(self.english_only_group)
|
||||||
|
|
||||||
|
# Languages override
|
||||||
|
self.langs_group = QGroupBox("Override languages")
|
||||||
|
self.langs_group.setCheckable(True)
|
||||||
|
self.langs_group.setChecked(self.settings.languages is not None)
|
||||||
|
langs_layout = QVBoxLayout(self.langs_group)
|
||||||
|
self.lang_list = QListWidget()
|
||||||
|
effective_langs = self.settings.languages if self.settings.languages is not None else self.config.languages
|
||||||
|
for code, name in LANGUAGE_NAMES.items():
|
||||||
|
item = QListWidgetItem(f"{name} ({code})")
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, code)
|
||||||
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
checked = code in effective_langs
|
||||||
|
item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
|
||||||
|
self.lang_list.addItem(item)
|
||||||
|
langs_layout.addWidget(self.lang_list)
|
||||||
|
layout.addWidget(self.langs_group)
|
||||||
|
|
||||||
|
# Bonus override
|
||||||
|
self.bonus_group = QGroupBox("Override bonus content")
|
||||||
|
self.bonus_group.setCheckable(True)
|
||||||
|
self.bonus_group.setChecked(self.settings.include_bonus is not None)
|
||||||
|
bonus_layout = QVBoxLayout(self.bonus_group)
|
||||||
|
self.bonus_cb = QCheckBox("Include bonus content")
|
||||||
|
self.bonus_cb.setChecked(
|
||||||
|
self.settings.include_bonus if self.settings.include_bonus is not None else self.config.include_bonus
|
||||||
|
)
|
||||||
|
bonus_layout.addWidget(self.bonus_cb)
|
||||||
|
layout.addWidget(self.bonus_group)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||||
|
buttons.accepted.connect(self._accept)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
def _accept(self) -> None:
|
||||||
|
# English only
|
||||||
|
english_only: bool | None = None
|
||||||
|
if self.english_only_group.isChecked():
|
||||||
|
english_only = self.english_only_cb.isChecked()
|
||||||
|
|
||||||
|
# Languages
|
||||||
|
languages: list[str] | None = None
|
||||||
|
if self.langs_group.isChecked():
|
||||||
|
languages = []
|
||||||
|
for i in range(self.lang_list.count()):
|
||||||
|
item = self.lang_list.item(i)
|
||||||
|
if item and item.checkState() == Qt.CheckState.Checked:
|
||||||
|
code = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
languages.append(code)
|
||||||
|
if not languages:
|
||||||
|
languages = ["en"]
|
||||||
|
|
||||||
|
# Bonus
|
||||||
|
include_bonus: bool | None = None
|
||||||
|
if self.bonus_group.isChecked():
|
||||||
|
include_bonus = self.bonus_cb.isChecked()
|
||||||
|
|
||||||
|
new_settings = GameSettings(
|
||||||
|
languages=languages,
|
||||||
|
english_only=english_only,
|
||||||
|
include_bonus=include_bonus,
|
||||||
|
)
|
||||||
|
self.config.set_game_settings(self.game_id, new_settings)
|
||||||
|
logger.info(f"Per-game settings saved for {self.game_id}: {new_settings}")
|
||||||
|
self.accept()
|
||||||
325
src/ui/dialog_game_versions.py
Normal file
325
src/ui/dialog_game_versions.py
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
"""Dialog showing downloaded versions of a game with management actions."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QTreeWidget,
|
||||||
|
QTreeWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.config import MetadataStore
|
||||||
|
from src.models import DownloadedInstaller, language_folder_name, sanitize_folder_name
|
||||||
|
|
||||||
|
COL_FILE = 0
|
||||||
|
COL_VERSION = 1
|
||||||
|
COL_LANG = 2
|
||||||
|
COL_SIZE = 3
|
||||||
|
COL_DATE = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_size(size: int) -> str:
|
||||||
|
for unit in ("B", "KB", "MB", "GB"):
|
||||||
|
if size < 1024:
|
||||||
|
return f"{size:.1f} {unit}"
|
||||||
|
size //= 1024
|
||||||
|
return f"{size:.1f} GB"
|
||||||
|
|
||||||
|
|
||||||
|
def _installer_path(base_path: Path, game_name: str, installer: DownloadedInstaller) -> Path:
|
||||||
|
"""Reconstruct the path where an installer file lives."""
|
||||||
|
lang_path = base_path / game_name / installer.version / language_folder_name(installer.language) / installer.filename
|
||||||
|
direct_path = base_path / game_name / installer.version / installer.filename
|
||||||
|
return lang_path if lang_path.exists() else direct_path
|
||||||
|
|
||||||
|
|
||||||
|
class GameVersionsDialog(QDialog):
|
||||||
|
"""Shows all downloaded versions/files for a game and lets user manage them."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
game_id: str,
|
||||||
|
game_title: str,
|
||||||
|
metadata: MetadataStore,
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.game_id = game_id
|
||||||
|
self.game_title = game_title
|
||||||
|
self.metadata = metadata
|
||||||
|
self.base_path = metadata.base_path
|
||||||
|
self.game_folder = self.base_path / sanitize_folder_name(game_title)
|
||||||
|
|
||||||
|
self.setWindowTitle(f"Downloaded versions — {game_title}")
|
||||||
|
self.setMinimumSize(700, 450)
|
||||||
|
self._setup_ui()
|
||||||
|
self._populate()
|
||||||
|
|
||||||
|
def _setup_ui(self) -> None:
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Header info
|
||||||
|
self.info_label = QLabel()
|
||||||
|
layout.addWidget(self.info_label)
|
||||||
|
|
||||||
|
# Installers group
|
||||||
|
inst_group = QGroupBox("Installer files")
|
||||||
|
inst_layout = QVBoxLayout(inst_group)
|
||||||
|
|
||||||
|
self.inst_tree = QTreeWidget()
|
||||||
|
self.inst_tree.setColumnCount(5)
|
||||||
|
self.inst_tree.setHeaderLabels(["File", "Version", "Language", "Size", "Downloaded"])
|
||||||
|
self.inst_tree.header().setStretchLastSection(False)
|
||||||
|
self.inst_tree.header().resizeSection(COL_FILE, 280)
|
||||||
|
self.inst_tree.header().resizeSection(COL_VERSION, 100)
|
||||||
|
self.inst_tree.header().resizeSection(COL_LANG, 100)
|
||||||
|
self.inst_tree.header().resizeSection(COL_SIZE, 80)
|
||||||
|
self.inst_tree.header().resizeSection(COL_DATE, 160)
|
||||||
|
self.inst_tree.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection)
|
||||||
|
self.inst_tree.setSortingEnabled(True)
|
||||||
|
inst_layout.addWidget(self.inst_tree)
|
||||||
|
|
||||||
|
inst_btn_row = QHBoxLayout()
|
||||||
|
self.delete_selected_btn = QPushButton("Delete selected files")
|
||||||
|
self.delete_selected_btn.clicked.connect(self._delete_selected_installers)
|
||||||
|
inst_btn_row.addWidget(self.delete_selected_btn)
|
||||||
|
|
||||||
|
self.delete_version_btn = QPushButton("Delete entire version folder…")
|
||||||
|
self.delete_version_btn.clicked.connect(self._delete_version_folder)
|
||||||
|
inst_btn_row.addWidget(self.delete_version_btn)
|
||||||
|
|
||||||
|
inst_btn_row.addStretch()
|
||||||
|
inst_layout.addLayout(inst_btn_row)
|
||||||
|
layout.addWidget(inst_group)
|
||||||
|
|
||||||
|
# Bonus group
|
||||||
|
bonus_group = QGroupBox("Bonus content")
|
||||||
|
bonus_layout = QVBoxLayout(bonus_group)
|
||||||
|
|
||||||
|
self.bonus_tree = QTreeWidget()
|
||||||
|
self.bonus_tree.setColumnCount(4)
|
||||||
|
self.bonus_tree.setHeaderLabels(["File", "Name", "Type", "Size"])
|
||||||
|
self.bonus_tree.header().setStretchLastSection(False)
|
||||||
|
self.bonus_tree.header().resizeSection(COL_FILE, 280)
|
||||||
|
self.bonus_tree.header().resizeSection(1, 180)
|
||||||
|
self.bonus_tree.header().resizeSection(2, 100)
|
||||||
|
self.bonus_tree.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection)
|
||||||
|
bonus_layout.addWidget(self.bonus_tree)
|
||||||
|
|
||||||
|
bonus_btn_row = QHBoxLayout()
|
||||||
|
self.delete_bonus_btn = QPushButton("Delete selected bonus files")
|
||||||
|
self.delete_bonus_btn.clicked.connect(self._delete_selected_bonuses)
|
||||||
|
bonus_btn_row.addWidget(self.delete_bonus_btn)
|
||||||
|
bonus_btn_row.addStretch()
|
||||||
|
bonus_layout.addLayout(bonus_btn_row)
|
||||||
|
layout.addWidget(bonus_group)
|
||||||
|
|
||||||
|
# Close button
|
||||||
|
close_btn = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||||
|
close_btn.rejected.connect(self.reject)
|
||||||
|
layout.addWidget(close_btn)
|
||||||
|
|
||||||
|
def _populate(self) -> None:
|
||||||
|
record = self.metadata.get_game(self.game_id)
|
||||||
|
if not record:
|
||||||
|
self.info_label.setText("No downloaded data found for this game.")
|
||||||
|
return
|
||||||
|
|
||||||
|
total_size = sum(i.size for i in record.installers) + sum(b.size for b in record.bonuses)
|
||||||
|
self.info_label.setText(
|
||||||
|
f"<b>{self.game_title}</b> — {len(record.installers)} installer file(s), "
|
||||||
|
f"{len(record.bonuses)} bonus file(s) — total {_fmt_size(total_size)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Group installers by version for visual clarity
|
||||||
|
versions: dict[str, list[DownloadedInstaller]] = {}
|
||||||
|
for inst in record.installers:
|
||||||
|
versions.setdefault(inst.version, []).append(inst)
|
||||||
|
|
||||||
|
self.inst_tree.clear()
|
||||||
|
for version in sorted(versions):
|
||||||
|
ver_node = QTreeWidgetItem()
|
||||||
|
ver_node.setText(COL_FILE, f"Version {version}")
|
||||||
|
ver_node.setText(COL_VERSION, version)
|
||||||
|
bold = QFont()
|
||||||
|
bold.setBold(True)
|
||||||
|
ver_node.setFont(COL_FILE, bold)
|
||||||
|
ver_node.setData(COL_FILE, Qt.ItemDataRole.UserRole, ("version", version))
|
||||||
|
|
||||||
|
ver_size = sum(i.size for i in versions[version])
|
||||||
|
ver_node.setText(COL_SIZE, _fmt_size(ver_size))
|
||||||
|
|
||||||
|
for inst in versions[version]:
|
||||||
|
file_node = QTreeWidgetItem()
|
||||||
|
file_node.setText(COL_FILE, inst.filename)
|
||||||
|
file_node.setText(COL_VERSION, inst.version)
|
||||||
|
file_node.setText(COL_LANG, language_folder_name(inst.language))
|
||||||
|
file_node.setText(COL_SIZE, _fmt_size(inst.size))
|
||||||
|
file_node.setText(COL_DATE, inst.downloaded_at[:19].replace("T", " ") if inst.downloaded_at else "")
|
||||||
|
file_node.setData(COL_FILE, Qt.ItemDataRole.UserRole, ("installer", inst))
|
||||||
|
|
||||||
|
# Grey out if file no longer on disk
|
||||||
|
path = _installer_path(self.base_path, sanitize_folder_name(self.game_title), inst)
|
||||||
|
if not path.exists():
|
||||||
|
for col in range(5):
|
||||||
|
file_node.setForeground(col, self.palette().color(self.foregroundRole()).darker(150))
|
||||||
|
file_node.setToolTip(COL_FILE, "File not found on disk")
|
||||||
|
|
||||||
|
ver_node.addChild(file_node)
|
||||||
|
|
||||||
|
ver_node.setExpanded(True)
|
||||||
|
self.inst_tree.addTopLevelItem(ver_node)
|
||||||
|
|
||||||
|
# Bonus files
|
||||||
|
self.bonus_tree.clear()
|
||||||
|
for bonus in record.bonuses:
|
||||||
|
node = QTreeWidgetItem()
|
||||||
|
node.setText(0, bonus.filename)
|
||||||
|
node.setText(1, bonus.name)
|
||||||
|
node.setText(2, bonus.bonus_type)
|
||||||
|
node.setText(3, _fmt_size(bonus.size))
|
||||||
|
node.setData(0, Qt.ItemDataRole.UserRole, bonus)
|
||||||
|
|
||||||
|
bonus_path = self.base_path / sanitize_folder_name(self.game_title) / "Bonus" / bonus.filename
|
||||||
|
if not bonus_path.exists():
|
||||||
|
for col in range(4):
|
||||||
|
node.setForeground(col, self.palette().color(self.foregroundRole()).darker(150))
|
||||||
|
node.setToolTip(0, "File not found on disk")
|
||||||
|
|
||||||
|
self.bonus_tree.addTopLevelItem(node)
|
||||||
|
|
||||||
|
def _delete_selected_installers(self) -> None:
|
||||||
|
selected = [
|
||||||
|
item for item in self.inst_tree.selectedItems()
|
||||||
|
if item.data(COL_FILE, Qt.ItemDataRole.UserRole) and
|
||||||
|
item.data(COL_FILE, Qt.ItemDataRole.UserRole)[0] == "installer"
|
||||||
|
]
|
||||||
|
if not selected:
|
||||||
|
QMessageBox.information(self, "Delete", "Select one or more installer files first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Delete files",
|
||||||
|
f"Delete {len(selected)} selected file(s) from disk?\nMetadata will be updated.",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
record = self.metadata.get_game(self.game_id)
|
||||||
|
if not record:
|
||||||
|
return
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
for item in selected:
|
||||||
|
inst: DownloadedInstaller = item.data(COL_FILE, Qt.ItemDataRole.UserRole)[1]
|
||||||
|
path = _installer_path(self.base_path, sanitize_folder_name(self.game_title), inst)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
logger.info(f"Deleted {path}")
|
||||||
|
deleted += 1
|
||||||
|
record.installers = [i for i in record.installers if i.filename != inst.filename or i.version != inst.version]
|
||||||
|
|
||||||
|
# Update latest_version
|
||||||
|
if record.installers:
|
||||||
|
record.latest_version = sorted({i.version for i in record.installers})[-1]
|
||||||
|
else:
|
||||||
|
record.latest_version = ""
|
||||||
|
|
||||||
|
self.metadata.update_game(record)
|
||||||
|
self._populate()
|
||||||
|
QMessageBox.information(self, "Done", f"Deleted {deleted} file(s).")
|
||||||
|
|
||||||
|
def _delete_version_folder(self) -> None:
|
||||||
|
"""Delete an entire version directory after user selects which version."""
|
||||||
|
record = self.metadata.get_game(self.game_id)
|
||||||
|
if not record:
|
||||||
|
return
|
||||||
|
|
||||||
|
versions = sorted({i.version for i in record.installers})
|
||||||
|
if not versions:
|
||||||
|
QMessageBox.information(self, "Delete", "No versions found in metadata.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pick version from selected item if any
|
||||||
|
selected = self.inst_tree.currentItem()
|
||||||
|
preselected_version = ""
|
||||||
|
if selected:
|
||||||
|
data = selected.data(COL_FILE, Qt.ItemDataRole.UserRole)
|
||||||
|
if data and data[0] == "version":
|
||||||
|
preselected_version = data[1]
|
||||||
|
elif data and data[0] == "installer":
|
||||||
|
preselected_version = data[1].version
|
||||||
|
|
||||||
|
version = preselected_version or versions[-1]
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Delete version folder",
|
||||||
|
f"Delete entire version folder '{version}' for '{self.game_title}'?\n\n"
|
||||||
|
f"Path: {self.base_path / sanitize_folder_name(self.game_title) / version}\n\n"
|
||||||
|
"This will remove all files in that folder from disk.",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
version_dir = self.base_path / sanitize_folder_name(self.game_title) / version
|
||||||
|
if version_dir.exists():
|
||||||
|
shutil.rmtree(version_dir)
|
||||||
|
logger.info(f"Deleted version folder {version_dir}")
|
||||||
|
|
||||||
|
record.installers = [i for i in record.installers if i.version != version]
|
||||||
|
if record.installers:
|
||||||
|
record.latest_version = sorted({i.version for i in record.installers})[-1]
|
||||||
|
else:
|
||||||
|
record.latest_version = ""
|
||||||
|
|
||||||
|
self.metadata.update_game(record)
|
||||||
|
self._populate()
|
||||||
|
|
||||||
|
def _delete_selected_bonuses(self) -> None:
|
||||||
|
selected = self.bonus_tree.selectedItems()
|
||||||
|
if not selected:
|
||||||
|
QMessageBox.information(self, "Delete", "Select one or more bonus files first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Delete bonus files",
|
||||||
|
f"Delete {len(selected)} bonus file(s) from disk?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
record = self.metadata.get_game(self.game_id)
|
||||||
|
if not record:
|
||||||
|
return
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
for item in selected:
|
||||||
|
bonus = item.data(0, Qt.ItemDataRole.UserRole)
|
||||||
|
path = self.base_path / sanitize_folder_name(self.game_title) / "Bonus" / bonus.filename
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
logger.info(f"Deleted bonus {path}")
|
||||||
|
deleted += 1
|
||||||
|
record.bonuses = [b for b in record.bonuses if b.filename != bonus.filename]
|
||||||
|
|
||||||
|
self.metadata.update_game(record)
|
||||||
|
self._populate()
|
||||||
|
QMessageBox.information(self, "Done", f"Deleted {deleted} bonus file(s).")
|
||||||
70
src/ui/main_window.py
Normal file
70
src/ui/main_window.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Main application window with tab layout."""
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from PySide6.QtWidgets import QMainWindow, QTabWidget
|
||||||
|
|
||||||
|
from src.api import GogApi
|
||||||
|
from src.auth import AuthManager
|
||||||
|
from src.config import AppConfig
|
||||||
|
from src.constants import APP_TITLE
|
||||||
|
from src.ui.tab_auth import AuthTab
|
||||||
|
from src.ui.tab_languages import LanguagesTab
|
||||||
|
from src.ui.tab_library import LibraryTab
|
||||||
|
from src.ui.tab_settings import SettingsTab
|
||||||
|
from src.ui.tab_status import StatusTab
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
"""Main GOGUpdater window with 5 tabs."""
|
||||||
|
|
||||||
|
def __init__(self, auth: AuthManager, api: GogApi, config: AppConfig) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.auth = auth
|
||||||
|
self.api = api
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
self.setWindowTitle(APP_TITLE)
|
||||||
|
self.setMinimumSize(900, 600)
|
||||||
|
|
||||||
|
self._setup_tabs()
|
||||||
|
self._connect_signals()
|
||||||
|
|
||||||
|
def _setup_tabs(self) -> None:
|
||||||
|
self.tabs = QTabWidget()
|
||||||
|
self.setCentralWidget(self.tabs)
|
||||||
|
|
||||||
|
self.auth_tab = AuthTab(self.auth)
|
||||||
|
self.library_tab = LibraryTab(self.api, self.config)
|
||||||
|
self.languages_tab = LanguagesTab(self.config)
|
||||||
|
self.settings_tab = SettingsTab(self.api, self.config)
|
||||||
|
self.status_tab = StatusTab(self.api, self.config)
|
||||||
|
|
||||||
|
self.tabs.addTab(self.auth_tab, "Login")
|
||||||
|
self.tabs.addTab(self.library_tab, "Library")
|
||||||
|
self.tabs.addTab(self.languages_tab, "Languages")
|
||||||
|
self.tabs.addTab(self.settings_tab, "Settings")
|
||||||
|
self.tabs.addTab(self.status_tab, "Status")
|
||||||
|
|
||||||
|
self._update_tab_states()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def _on_login_changed(self, logged_in: bool) -> None:
|
||||||
|
logger.info(f"Login state changed: logged_in={logged_in}")
|
||||||
|
self._update_tab_states()
|
||||||
|
|
||||||
|
def _on_english_only_changed(self, english_only: bool) -> None:
|
||||||
|
"""Enable/disable Languages tab based on english_only setting."""
|
||||||
|
logger.info(f"English only changed: {english_only}")
|
||||||
|
self._update_tab_states()
|
||||||
|
|
||||||
|
def _update_tab_states(self) -> None:
|
||||||
|
"""Enable/disable tabs based on login state and settings."""
|
||||||
|
logged_in = self.auth.is_logged_in
|
||||||
|
for i in range(1, self.tabs.count()):
|
||||||
|
self.tabs.setTabEnabled(i, logged_in)
|
||||||
|
# Languages tab (index 2) disabled when english_only
|
||||||
|
if logged_in and self.config.english_only:
|
||||||
|
self.tabs.setTabEnabled(2, False)
|
||||||
116
src/ui/tab_auth.py
Normal file
116
src/ui/tab_auth.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Authentication tab — GOG OAuth2 login flow."""
|
||||||
|
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from PySide6.QtCore import Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from src.auth import LOGIN_URL, AuthManager
|
||||||
|
|
||||||
|
|
||||||
|
class AuthTab(QWidget):
|
||||||
|
"""Tab for GOG account authentication."""
|
||||||
|
|
||||||
|
login_state_changed = Signal(bool)
|
||||||
|
|
||||||
|
def __init__(self, auth: AuthManager, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.auth = auth
|
||||||
|
self._setup_ui()
|
||||||
|
self._update_status()
|
||||||
|
|
||||||
|
def _setup_ui(self) -> None:
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
self.status_label = QLabel()
|
||||||
|
self.status_label.setStyleSheet("font-size: 14px; font-weight: bold;")
|
||||||
|
layout.addWidget(self.status_label)
|
||||||
|
|
||||||
|
layout.addSpacing(20)
|
||||||
|
|
||||||
|
# Step 1: Open browser
|
||||||
|
step1_label = QLabel("1. Open GOG login page in your browser:")
|
||||||
|
layout.addWidget(step1_label)
|
||||||
|
|
||||||
|
self.open_browser_btn = QPushButton("Open GOG Login")
|
||||||
|
self.open_browser_btn.setFixedWidth(200)
|
||||||
|
self.open_browser_btn.clicked.connect(self._open_login)
|
||||||
|
layout.addWidget(self.open_browser_btn)
|
||||||
|
|
||||||
|
layout.addSpacing(10)
|
||||||
|
|
||||||
|
# Step 2: Paste code
|
||||||
|
step2_label = QLabel("2. After login, paste the authorization code from the URL:")
|
||||||
|
layout.addWidget(step2_label)
|
||||||
|
|
||||||
|
code_layout = QHBoxLayout()
|
||||||
|
self.code_input = QLineEdit()
|
||||||
|
self.code_input.setPlaceholderText("Paste authorization code here...")
|
||||||
|
self.code_input.returnPressed.connect(self._submit_code)
|
||||||
|
code_layout.addWidget(self.code_input)
|
||||||
|
|
||||||
|
self.submit_btn = QPushButton("Login")
|
||||||
|
self.submit_btn.clicked.connect(self._submit_code)
|
||||||
|
code_layout.addWidget(self.submit_btn)
|
||||||
|
|
||||||
|
layout.addLayout(code_layout)
|
||||||
|
|
||||||
|
layout.addSpacing(20)
|
||||||
|
|
||||||
|
# Logout
|
||||||
|
self.logout_btn = QPushButton("Logout")
|
||||||
|
self.logout_btn.setFixedWidth(200)
|
||||||
|
self.logout_btn.clicked.connect(self._logout)
|
||||||
|
layout.addWidget(self.logout_btn)
|
||||||
|
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
def _update_status(self) -> None:
|
||||||
|
if self.auth.is_logged_in:
|
||||||
|
self.status_label.setText("Status: Logged in")
|
||||||
|
self.status_label.setStyleSheet("font-size: 14px; font-weight: bold; color: green;")
|
||||||
|
self.code_input.setEnabled(False)
|
||||||
|
self.submit_btn.setEnabled(False)
|
||||||
|
self.open_browser_btn.setEnabled(False)
|
||||||
|
self.logout_btn.setEnabled(True)
|
||||||
|
else:
|
||||||
|
self.status_label.setText("Status: Not logged in")
|
||||||
|
self.status_label.setStyleSheet("font-size: 14px; font-weight: bold; color: red;")
|
||||||
|
self.code_input.setEnabled(True)
|
||||||
|
self.submit_btn.setEnabled(True)
|
||||||
|
self.open_browser_btn.setEnabled(True)
|
||||||
|
self.logout_btn.setEnabled(False)
|
||||||
|
|
||||||
|
def _open_login(self) -> None:
|
||||||
|
logger.info("Opening GOG login page in browser")
|
||||||
|
webbrowser.open(LOGIN_URL)
|
||||||
|
|
||||||
|
def _submit_code(self) -> None:
|
||||||
|
code = self.code_input.text().strip()
|
||||||
|
if not code:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Submitting authorization code")
|
||||||
|
if self.auth.exchange_code(code):
|
||||||
|
self._update_status()
|
||||||
|
self.login_state_changed.emit(True)
|
||||||
|
self.code_input.clear()
|
||||||
|
else:
|
||||||
|
logger.warning("Authorization code exchange failed")
|
||||||
|
QMessageBox.warning(self, "Login Failed", "Failed to authenticate. Check the code and try again.")
|
||||||
|
|
||||||
|
def _logout(self) -> None:
|
||||||
|
logger.info("User logging out")
|
||||||
|
self.auth.logout()
|
||||||
|
self._update_status()
|
||||||
|
self.login_state_changed.emit(False)
|
||||||
62
src/ui/tab_languages.py
Normal file
62
src/ui/tab_languages.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Languages tab — select which languages to manage installers for."""
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QLabel,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.config import AppConfig
|
||||||
|
from src.models import LANGUAGE_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
class LanguagesTab(QWidget):
|
||||||
|
"""Tab for selecting which languages to download installers for."""
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.config = config
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self) -> None:
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
label = QLabel("Select languages for which installers will be downloaded:")
|
||||||
|
layout.addWidget(label)
|
||||||
|
|
||||||
|
self.lang_list = QListWidget()
|
||||||
|
for code, name in LANGUAGE_NAMES.items():
|
||||||
|
item = QListWidgetItem(f"{name} ({code})")
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, code)
|
||||||
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
checked = code in self.config.languages
|
||||||
|
item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
|
||||||
|
self.lang_list.addItem(item)
|
||||||
|
|
||||||
|
self.lang_list.itemChanged.connect(self._on_item_changed)
|
||||||
|
layout.addWidget(self.lang_list)
|
||||||
|
|
||||||
|
save_btn = QPushButton("Save")
|
||||||
|
save_btn.setFixedWidth(200)
|
||||||
|
save_btn.clicked.connect(self._save)
|
||||||
|
layout.addWidget(save_btn)
|
||||||
|
|
||||||
|
def _on_item_changed(self, item: QListWidgetItem) -> None:
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
selected: list[str] = []
|
||||||
|
for i in range(self.lang_list.count()):
|
||||||
|
item = self.lang_list.item(i)
|
||||||
|
if item and item.checkState() == Qt.CheckState.Checked:
|
||||||
|
code = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
selected.append(code)
|
||||||
|
self.config.languages = selected if selected else ["en"]
|
||||||
|
self.config.save()
|
||||||
|
logger.info(f"Languages updated: {self.config.languages}")
|
||||||
224
src/ui/tab_library.py
Normal file
224
src/ui/tab_library.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"""Library tab — owned games with management checkboxes, DLCs as sub-items."""
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QTabWidget,
|
||||||
|
QTreeWidget,
|
||||||
|
QTreeWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.api import GogApi
|
||||||
|
from src.config import AppConfig
|
||||||
|
from src.models import InstallerPlatform
|
||||||
|
from src.ui.dialog_game_settings import GameSettingsDialog
|
||||||
|
|
||||||
|
COL_TITLE = 0
|
||||||
|
COL_ID = 1
|
||||||
|
COL_OVERRIDES = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tree() -> QTreeWidget:
|
||||||
|
tree = QTreeWidget()
|
||||||
|
tree.setColumnCount(3)
|
||||||
|
tree.setHeaderLabels(["Game Title", "Game ID", "Overrides"])
|
||||||
|
tree.header().setSectionResizeMode(COL_TITLE, QHeaderView.ResizeMode.Stretch)
|
||||||
|
tree.setRootIsDecorated(True)
|
||||||
|
tree.setSortingEnabled(True)
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryTab(QWidget):
|
||||||
|
"""Tab showing owned games with checkboxes. DLCs appear as children of their parent game."""
|
||||||
|
|
||||||
|
managed_games_changed = Signal()
|
||||||
|
|
||||||
|
def __init__(self, api: GogApi, config: AppConfig, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.api = api
|
||||||
|
self.config = config
|
||||||
|
self._updating = False
|
||||||
|
# store games_data per platform for re-populating after settings change
|
||||||
|
self._games_data: dict[InstallerPlatform, list[tuple[str, str, list[tuple[str, str]]]]] = {
|
||||||
|
InstallerPlatform.WINDOWS: [],
|
||||||
|
InstallerPlatform.LINUX: [],
|
||||||
|
}
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self) -> None:
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
top_row = QPushButton("Refresh Library")
|
||||||
|
top_row.setFixedWidth(200)
|
||||||
|
top_row.clicked.connect(self.refresh_library)
|
||||||
|
layout.addWidget(top_row)
|
||||||
|
|
||||||
|
self.status_label = QLabel("Click 'Refresh Library' to load your games.")
|
||||||
|
layout.addWidget(self.status_label)
|
||||||
|
|
||||||
|
self.platform_tabs = QTabWidget()
|
||||||
|
self.tree_windows = _make_tree()
|
||||||
|
self.tree_linux = _make_tree()
|
||||||
|
self.platform_tabs.addTab(self.tree_windows, "Windows")
|
||||||
|
self.platform_tabs.addTab(self.tree_linux, "Linux")
|
||||||
|
layout.addWidget(self.platform_tabs)
|
||||||
|
|
||||||
|
self.tree_windows.itemChanged.connect(self._on_item_changed)
|
||||||
|
self.tree_linux.itemChanged.connect(self._on_item_changed)
|
||||||
|
self.tree_windows.itemDoubleClicked.connect(self._on_item_double_clicked)
|
||||||
|
self.tree_linux.itemDoubleClicked.connect(self._on_item_double_clicked)
|
||||||
|
|
||||||
|
self._update_platform_tab_visibility()
|
||||||
|
|
||||||
|
def _update_platform_tab_visibility(self) -> None:
|
||||||
|
self.platform_tabs.setTabVisible(0, bool(self.config.windows_path))
|
||||||
|
self.platform_tabs.setTabVisible(1, bool(self.config.linux_path))
|
||||||
|
|
||||||
|
def refresh_library(self) -> None:
|
||||||
|
"""Fetch owned games from GOG API and populate both platform trees."""
|
||||||
|
self.status_label.setText("Loading library...")
|
||||||
|
self.tree_windows.clear()
|
||||||
|
self.tree_linux.clear()
|
||||||
|
self._update_platform_tab_visibility()
|
||||||
|
logger.info("Refreshing game library")
|
||||||
|
|
||||||
|
# TODO: Run in a thread to avoid blocking the GUI
|
||||||
|
owned_ids = self.api.get_owned_game_ids()
|
||||||
|
if not owned_ids:
|
||||||
|
logger.warning("No owned games found or not logged in")
|
||||||
|
self.status_label.setText("No games found or not logged in.")
|
||||||
|
return
|
||||||
|
|
||||||
|
owned_set = {str(gid) for gid in owned_ids}
|
||||||
|
dlc_ids: set[str] = set()
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
for gid in owned_ids:
|
||||||
|
product = self.api.get_product_info(gid)
|
||||||
|
if not product:
|
||||||
|
games_data.append((str(gid), f"Game {gid}", [], set()))
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = product.get("title", f"Game {gid}")
|
||||||
|
dlcs: list[tuple[str, str]] = []
|
||||||
|
for dlc in product.get("expanded_dlcs", []):
|
||||||
|
dlc_id = str(dlc.get("id", ""))
|
||||||
|
if dlc_id in owned_set:
|
||||||
|
dlc_ids.add(dlc_id)
|
||||||
|
dlcs.append((dlc_id, dlc.get("title", f"DLC {dlc_id}")))
|
||||||
|
|
||||||
|
# Detect which platforms have installers
|
||||||
|
installers = product.get("downloads", {}).get("installers", [])
|
||||||
|
platforms: set[str] = {inst.get("os", "") for inst in installers}
|
||||||
|
|
||||||
|
games_data.append((str(gid), title, dlcs, platforms))
|
||||||
|
|
||||||
|
# Filter out DLC top-level entries and sort
|
||||||
|
games_data = [(gid, title, dlcs, plats) for gid, title, dlcs, plats in games_data if gid not in dlc_ids]
|
||||||
|
games_data.sort(key=lambda x: x[1].lower())
|
||||||
|
|
||||||
|
self._updating = True
|
||||||
|
self.tree_windows.setSortingEnabled(False)
|
||||||
|
self.tree_linux.setSortingEnabled(False)
|
||||||
|
|
||||||
|
win_count = 0
|
||||||
|
lin_count = 0
|
||||||
|
|
||||||
|
for game_id, title, dlcs, platforms in games_data:
|
||||||
|
managed = self.config.is_game_managed(game_id)
|
||||||
|
check = Qt.CheckState.Checked if managed else Qt.CheckState.Unchecked
|
||||||
|
|
||||||
|
if "windows" in platforms and self.config.windows_path:
|
||||||
|
node = self._make_node(game_id, title, check)
|
||||||
|
self.tree_windows.addTopLevelItem(node)
|
||||||
|
for dlc_id, dlc_title in dlcs:
|
||||||
|
child = self._make_node(dlc_id, dlc_title, check)
|
||||||
|
node.addChild(child)
|
||||||
|
win_count += 1
|
||||||
|
|
||||||
|
if "linux" in platforms and self.config.linux_path:
|
||||||
|
node = self._make_node(game_id, title, check)
|
||||||
|
self.tree_linux.addTopLevelItem(node)
|
||||||
|
for dlc_id, dlc_title in dlcs:
|
||||||
|
child = self._make_node(dlc_id, dlc_title, check)
|
||||||
|
node.addChild(child)
|
||||||
|
lin_count += 1
|
||||||
|
|
||||||
|
self.tree_windows.setSortingEnabled(True)
|
||||||
|
self.tree_linux.setSortingEnabled(True)
|
||||||
|
self._updating = False
|
||||||
|
|
||||||
|
logger.info(f"Library loaded: {win_count} Windows, {lin_count} Linux games")
|
||||||
|
self.status_label.setText(
|
||||||
|
f"Loaded {win_count} Windows / {lin_count} Linux games. Double-click to configure per-game settings."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_node(self, game_id: str, title: str, check: Qt.CheckState) -> QTreeWidgetItem:
|
||||||
|
node = QTreeWidgetItem()
|
||||||
|
node.setText(COL_TITLE, title)
|
||||||
|
node.setText(COL_ID, game_id)
|
||||||
|
node.setFlags(node.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
node.setCheckState(COL_TITLE, check)
|
||||||
|
self._update_override_label(node, game_id)
|
||||||
|
return node
|
||||||
|
|
||||||
|
def _on_item_changed(self, item: QTreeWidgetItem, column: int) -> None:
|
||||||
|
if self._updating or column != COL_TITLE:
|
||||||
|
return
|
||||||
|
|
||||||
|
game_id = item.text(COL_ID)
|
||||||
|
managed = item.checkState(COL_TITLE) == Qt.CheckState.Checked
|
||||||
|
|
||||||
|
self._updating = True
|
||||||
|
if item.parent() is None:
|
||||||
|
logger.debug(f"Game {game_id} managed={managed}")
|
||||||
|
self.config.set_game_managed(game_id, managed)
|
||||||
|
for i in range(item.childCount()):
|
||||||
|
child = item.child(i)
|
||||||
|
if child:
|
||||||
|
child.setCheckState(COL_TITLE, item.checkState(COL_TITLE))
|
||||||
|
self.config.set_game_managed(child.text(COL_ID), managed)
|
||||||
|
else:
|
||||||
|
logger.debug(f"DLC {game_id} managed={managed}")
|
||||||
|
self.config.set_game_managed(game_id, managed)
|
||||||
|
self._updating = False
|
||||||
|
|
||||||
|
self.managed_games_changed.emit()
|
||||||
|
|
||||||
|
def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
|
||||||
|
game_id = item.text(COL_ID)
|
||||||
|
game_title = item.text(COL_TITLE)
|
||||||
|
dialog = GameSettingsDialog(game_id, game_title, self.config, self)
|
||||||
|
if dialog.exec():
|
||||||
|
# Update override label in both trees
|
||||||
|
for tree in (self.tree_windows, self.tree_linux):
|
||||||
|
for i in range(tree.topLevelItemCount()):
|
||||||
|
top = tree.topLevelItem(i)
|
||||||
|
if top and top.text(COL_ID) == game_id:
|
||||||
|
self._update_override_label(top, game_id)
|
||||||
|
if top:
|
||||||
|
for j in range(top.childCount()):
|
||||||
|
child = top.child(j)
|
||||||
|
if child and child.text(COL_ID) == game_id:
|
||||||
|
self._update_override_label(child, game_id)
|
||||||
|
|
||||||
|
def _update_override_label(self, item: QTreeWidgetItem, game_id: str) -> None:
|
||||||
|
gs = self.config.get_game_settings(game_id)
|
||||||
|
if gs.is_default():
|
||||||
|
item.setText(COL_OVERRIDES, "")
|
||||||
|
return
|
||||||
|
parts: list[str] = []
|
||||||
|
if gs.english_only is not None:
|
||||||
|
parts.append("EN only" if gs.english_only else "multilang")
|
||||||
|
if gs.languages is not None:
|
||||||
|
parts.append(f"{len(gs.languages)} lang(s)")
|
||||||
|
if gs.include_bonus is not None:
|
||||||
|
parts.append("bonus" if gs.include_bonus else "no bonus")
|
||||||
|
item.setText(COL_OVERRIDES, ", ".join(parts))
|
||||||
167
src/ui/tab_settings.py
Normal file
167
src/ui/tab_settings.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Settings tab — installer paths configuration."""
|
||||||
|
|
||||||
|
from PySide6.QtCore import Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QFileDialog,
|
||||||
|
QFormLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.api import GogApi
|
||||||
|
from src.config import AppConfig, MetadataStore
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsTab(QWidget):
|
||||||
|
"""Tab for configuring installer storage paths."""
|
||||||
|
|
||||||
|
english_only_changed = Signal(bool)
|
||||||
|
|
||||||
|
def __init__(self, api: GogApi, config: AppConfig, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.api = api
|
||||||
|
self.config = config
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self) -> None:
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
form = QFormLayout()
|
||||||
|
|
||||||
|
# Windows installers path
|
||||||
|
win_layout = QHBoxLayout()
|
||||||
|
self.win_path_input = QLineEdit(self.config.windows_path)
|
||||||
|
self.win_path_input.setPlaceholderText("Path for Windows installers (.exe)")
|
||||||
|
win_layout.addWidget(self.win_path_input)
|
||||||
|
win_browse_btn = QPushButton("Browse...")
|
||||||
|
win_browse_btn.clicked.connect(lambda: self._browse("windows"))
|
||||||
|
win_layout.addWidget(win_browse_btn)
|
||||||
|
form.addRow("Windows installers:", win_layout)
|
||||||
|
|
||||||
|
# Linux installers path
|
||||||
|
linux_layout = QHBoxLayout()
|
||||||
|
self.linux_path_input = QLineEdit(self.config.linux_path)
|
||||||
|
self.linux_path_input.setPlaceholderText("Path for Linux installers (.sh)")
|
||||||
|
linux_layout.addWidget(self.linux_path_input)
|
||||||
|
linux_browse_btn = QPushButton("Browse...")
|
||||||
|
linux_browse_btn.clicked.connect(lambda: self._browse("linux"))
|
||||||
|
linux_layout.addWidget(linux_browse_btn)
|
||||||
|
form.addRow("Linux installers:", linux_layout)
|
||||||
|
|
||||||
|
layout.addLayout(form)
|
||||||
|
|
||||||
|
layout.addSpacing(10)
|
||||||
|
|
||||||
|
# English only checkbox
|
||||||
|
self.english_only_cb = QCheckBox("English only (disable multilingual support)")
|
||||||
|
self.english_only_cb.setChecked(self.config.english_only)
|
||||||
|
self.english_only_cb.toggled.connect(self._on_english_only_toggled)
|
||||||
|
layout.addWidget(self.english_only_cb)
|
||||||
|
|
||||||
|
# Include bonus content checkbox
|
||||||
|
self.include_bonus_cb = QCheckBox("Include bonus content (soundtracks, wallpapers, manuals, ...)")
|
||||||
|
self.include_bonus_cb.setChecked(self.config.include_bonus)
|
||||||
|
self.include_bonus_cb.toggled.connect(self._on_include_bonus_toggled)
|
||||||
|
layout.addWidget(self.include_bonus_cb)
|
||||||
|
|
||||||
|
layout.addSpacing(20)
|
||||||
|
|
||||||
|
# Buttons row
|
||||||
|
btn_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
save_btn = QPushButton("Save Settings")
|
||||||
|
save_btn.setFixedWidth(200)
|
||||||
|
save_btn.clicked.connect(self._save)
|
||||||
|
btn_layout.addWidget(save_btn)
|
||||||
|
|
||||||
|
scan_btn = QPushButton("Scan Existing Installers")
|
||||||
|
scan_btn.setFixedWidth(200)
|
||||||
|
scan_btn.clicked.connect(self._scan_existing)
|
||||||
|
btn_layout.addWidget(scan_btn)
|
||||||
|
|
||||||
|
btn_layout.addStretch()
|
||||||
|
layout.addLayout(btn_layout)
|
||||||
|
|
||||||
|
self.status_label = QLabel("")
|
||||||
|
layout.addWidget(self.status_label)
|
||||||
|
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
def _browse(self, target: str) -> None:
|
||||||
|
path = QFileDialog.getExistingDirectory(self, f"Select {target} installers directory")
|
||||||
|
if path:
|
||||||
|
if target == "windows":
|
||||||
|
self.win_path_input.setText(path)
|
||||||
|
else:
|
||||||
|
self.linux_path_input.setText(path)
|
||||||
|
|
||||||
|
def _on_english_only_toggled(self, checked: bool) -> None:
|
||||||
|
self.config.english_only = checked
|
||||||
|
self.config.save()
|
||||||
|
self.english_only_changed.emit(checked)
|
||||||
|
logger.info(f"English only: {checked}")
|
||||||
|
|
||||||
|
def _on_include_bonus_toggled(self, checked: bool) -> None:
|
||||||
|
self.config.include_bonus = checked
|
||||||
|
self.config.save()
|
||||||
|
logger.info(f"Include bonus content: {checked}")
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
self.config.windows_path = self.win_path_input.text().strip()
|
||||||
|
self.config.linux_path = self.linux_path_input.text().strip()
|
||||||
|
self.config.english_only = self.english_only_cb.isChecked()
|
||||||
|
self.config.include_bonus = self.include_bonus_cb.isChecked()
|
||||||
|
self.config.save()
|
||||||
|
logger.info(f"Settings saved: windows={self.config.windows_path}, linux={self.config.linux_path}")
|
||||||
|
self.status_label.setText("Settings saved.")
|
||||||
|
|
||||||
|
def _scan_existing(self) -> None:
|
||||||
|
"""Scan configured paths for already downloaded installers."""
|
||||||
|
# Build title -> game_id mapping from owned games
|
||||||
|
self.status_label.setText("Fetching game library for matching...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
owned_games = self.api.get_owned_games()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch owned games for scan: {e}")
|
||||||
|
self.status_label.setText("Failed to fetch game library.")
|
||||||
|
return
|
||||||
|
|
||||||
|
title_to_id: dict[str, str] = {game.title: game.game_id for game in owned_games}
|
||||||
|
logger.info(f"Scan: loaded {len(title_to_id)} owned games for matching")
|
||||||
|
|
||||||
|
total_detected = 0
|
||||||
|
scanned_paths: list[str] = []
|
||||||
|
|
||||||
|
for path in [self.config.windows_path, self.config.linux_path]:
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
scanned_paths.append(path)
|
||||||
|
self.status_label.setText(f"Scanning {path}...")
|
||||||
|
metadata = MetadataStore(path)
|
||||||
|
detected = metadata.scan_existing_installers(title_to_id)
|
||||||
|
total_detected += detected
|
||||||
|
|
||||||
|
if not scanned_paths:
|
||||||
|
self.status_label.setText("No paths configured. Set Windows/Linux paths first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = f"Scan complete. Detected {total_detected} game(s) with existing installers."
|
||||||
|
self.status_label.setText(msg)
|
||||||
|
logger.info(msg)
|
||||||
|
|
||||||
|
if total_detected > 0:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Scan Complete",
|
||||||
|
f"Found {total_detected} game(s) with existing installers.\n\n"
|
||||||
|
"Their metadata has been recorded. Check the Status tab for details.",
|
||||||
|
)
|
||||||
569
src/ui/tab_status.py
Normal file
569
src/ui/tab_status.py
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
"""Status tab — installer status overview, update checks, downloads, pruning."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QBrush, QColor
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QMessageBox,
|
||||||
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
|
QTabWidget,
|
||||||
|
QTreeWidget,
|
||||||
|
QTreeWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.api import GogApi
|
||||||
|
from src.config import AppConfig, MetadataStore
|
||||||
|
from src.downloader import InstallerDownloader
|
||||||
|
from src.ui.dialog_game_versions import GameVersionsDialog
|
||||||
|
from src.models import (
|
||||||
|
BonusContent,
|
||||||
|
GameStatus,
|
||||||
|
GameStatusInfo,
|
||||||
|
InstallerPlatform,
|
||||||
|
LanguageVersionInfo,
|
||||||
|
language_folder_name,
|
||||||
|
)
|
||||||
|
from src.version_compare import CompareResult, compare_versions, format_comparison_question
|
||||||
|
|
||||||
|
STATUS_COLORS: dict[GameStatus, str] = {
|
||||||
|
GameStatus.UP_TO_DATE: "#2e7d32",
|
||||||
|
GameStatus.UPDATE_AVAILABLE: "#e65100",
|
||||||
|
GameStatus.NOT_DOWNLOADED: "#1565c0",
|
||||||
|
GameStatus.UNKNOWN: "#757575",
|
||||||
|
GameStatus.UNVERSIONED: "#f9a825",
|
||||||
|
}
|
||||||
|
|
||||||
|
COL_NAME = 0
|
||||||
|
COL_CURRENT = 1
|
||||||
|
COL_LATEST = 2
|
||||||
|
COL_STATUS = 3
|
||||||
|
COL_LANGS = 4
|
||||||
|
COL_BONUS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tree() -> QTreeWidget:
|
||||||
|
tree = QTreeWidget()
|
||||||
|
tree.setColumnCount(6)
|
||||||
|
tree.setHeaderLabels(["Game", "Current Version", "Latest Version", "Status", "Language", "Bonus"])
|
||||||
|
tree.header().setSectionResizeMode(COL_NAME, QHeaderView.ResizeMode.Stretch)
|
||||||
|
tree.setRootIsDecorated(True)
|
||||||
|
tree.setSortingEnabled(True)
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
class StatusTab(QWidget):
|
||||||
|
"""Tab showing installer status with check/download/prune controls."""
|
||||||
|
|
||||||
|
def __init__(self, api: GogApi, config: AppConfig, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.api = api
|
||||||
|
self.config = config
|
||||||
|
# per-platform status items
|
||||||
|
self.status_items: dict[InstallerPlatform, list[GameStatusInfo]] = {
|
||||||
|
InstallerPlatform.WINDOWS: [],
|
||||||
|
InstallerPlatform.LINUX: [],
|
||||||
|
}
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self) -> None:
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Controls
|
||||||
|
controls = QHBoxLayout()
|
||||||
|
|
||||||
|
self.check_btn = QPushButton("Check for Updates")
|
||||||
|
self.check_btn.clicked.connect(self.check_updates)
|
||||||
|
controls.addWidget(self.check_btn)
|
||||||
|
|
||||||
|
self.download_btn = QPushButton("Download Updates")
|
||||||
|
self.download_btn.clicked.connect(self.download_updates)
|
||||||
|
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)
|
||||||
|
|
||||||
|
self.prune_keep_combo = QComboBox()
|
||||||
|
self.prune_keep_combo.addItems(["Keep 1", "Keep 2", "Keep 3"])
|
||||||
|
controls.addWidget(self.prune_keep_combo)
|
||||||
|
|
||||||
|
self.show_unknown_cb = QCheckBox("Show unversioned/unknown")
|
||||||
|
self.show_unknown_cb.setChecked(False)
|
||||||
|
self.show_unknown_cb.toggled.connect(self._repopulate_current)
|
||||||
|
controls.addWidget(self.show_unknown_cb)
|
||||||
|
|
||||||
|
controls.addStretch()
|
||||||
|
layout.addLayout(controls)
|
||||||
|
|
||||||
|
# Status label
|
||||||
|
self.status_label = QLabel("Click 'Check for Updates' to scan managed games.")
|
||||||
|
layout.addWidget(self.status_label)
|
||||||
|
|
||||||
|
# Progress bar
|
||||||
|
self.progress_bar = QProgressBar()
|
||||||
|
self.progress_bar.setVisible(False)
|
||||||
|
layout.addWidget(self.progress_bar)
|
||||||
|
|
||||||
|
# Platform sub-tabs
|
||||||
|
self.platform_tabs = QTabWidget()
|
||||||
|
self.tree_windows = _make_tree()
|
||||||
|
self.tree_linux = _make_tree()
|
||||||
|
self.platform_tabs.addTab(self.tree_windows, "Windows")
|
||||||
|
self.platform_tabs.addTab(self.tree_linux, "Linux")
|
||||||
|
self.tree_windows.itemDoubleClicked.connect(self._on_item_double_clicked)
|
||||||
|
self.tree_linux.itemDoubleClicked.connect(self._on_item_double_clicked)
|
||||||
|
layout.addWidget(self.platform_tabs)
|
||||||
|
|
||||||
|
self._update_platform_tab_visibility()
|
||||||
|
|
||||||
|
def _update_platform_tab_visibility(self) -> None:
|
||||||
|
"""Show/hide platform tabs based on configured paths."""
|
||||||
|
has_win = bool(self.config.windows_path)
|
||||||
|
has_lin = bool(self.config.linux_path)
|
||||||
|
self.platform_tabs.setTabVisible(0, has_win)
|
||||||
|
self.platform_tabs.setTabVisible(1, has_lin)
|
||||||
|
|
||||||
|
def _current_platform(self) -> InstallerPlatform:
|
||||||
|
idx = self.platform_tabs.currentIndex()
|
||||||
|
return InstallerPlatform.WINDOWS if idx == 0 else InstallerPlatform.LINUX
|
||||||
|
|
||||||
|
def _tree_for(self, platform: InstallerPlatform) -> QTreeWidget:
|
||||||
|
return self.tree_windows if platform == InstallerPlatform.WINDOWS else self.tree_linux
|
||||||
|
|
||||||
|
def _base_path_for(self, platform: InstallerPlatform) -> str:
|
||||||
|
return self.config.windows_path if platform == InstallerPlatform.WINDOWS else self.config.linux_path
|
||||||
|
|
||||||
|
def check_updates(self) -> None:
|
||||||
|
"""Check update status for all managed games, per language."""
|
||||||
|
managed_ids = self.config.managed_game_ids
|
||||||
|
if not managed_ids:
|
||||||
|
self.status_label.setText("No managed games. Go to Library tab to select games.")
|
||||||
|
return
|
||||||
|
|
||||||
|
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
|
||||||
|
for platform in InstallerPlatform:
|
||||||
|
base_path = self._base_path_for(platform)
|
||||||
|
if base_path:
|
||||||
|
metadata = MetadataStore(base_path)
|
||||||
|
stale = metadata.verify_metadata()
|
||||||
|
if stale:
|
||||||
|
logger.info(f"Cleaned {stale} stale metadata entries from {base_path}")
|
||||||
|
|
||||||
|
owned_set = self.api.get_owned_ids_set()
|
||||||
|
managed_set = set(managed_ids)
|
||||||
|
|
||||||
|
# First pass: collect all DLC IDs and cache products
|
||||||
|
dlc_ids: set[str] = 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
|
||||||
|
for dlc in product.get("expanded_dlcs", []):
|
||||||
|
dlc_ids.add(str(dlc.get("id", "")))
|
||||||
|
|
||||||
|
for platform in InstallerPlatform:
|
||||||
|
base_path = self._base_path_for(platform)
|
||||||
|
if not base_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
metadata = MetadataStore(base_path)
|
||||||
|
items_list = self.status_items[platform]
|
||||||
|
|
||||||
|
for game_id in managed_ids:
|
||||||
|
if game_id in dlc_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
product = products_cache.get(game_id)
|
||||||
|
if not product:
|
||||||
|
items_list.append(GameStatusInfo(
|
||||||
|
game_id=game_id,
|
||||||
|
name=f"Game {game_id}",
|
||||||
|
current_version="",
|
||||||
|
latest_version="",
|
||||||
|
status=GameStatus.UNKNOWN,
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
game_name = product.get("title", f"Game {game_id}")
|
||||||
|
game_status = self._build_game_status(game_id, game_name, product, platform, metadata)
|
||||||
|
|
||||||
|
for dlc in product.get("expanded_dlcs", []):
|
||||||
|
dlc_id = str(dlc.get("id", ""))
|
||||||
|
if dlc_id not in owned_set or dlc_id not in managed_set:
|
||||||
|
continue
|
||||||
|
dlc_name = dlc.get("title", f"DLC {dlc_id}")
|
||||||
|
dlc_status = self._build_game_status(dlc_id, dlc_name, dlc, platform, metadata, parent_name=game_name)
|
||||||
|
game_status.dlcs.append(dlc_status)
|
||||||
|
|
||||||
|
items_list.append(game_status)
|
||||||
|
|
||||||
|
self._populate_tree(InstallerPlatform.WINDOWS)
|
||||||
|
self._populate_tree(InstallerPlatform.LINUX)
|
||||||
|
self.download_btn.setEnabled(self._has_downloadable_items())
|
||||||
|
self.check_btn.setEnabled(True)
|
||||||
|
|
||||||
|
total = sum(len(items) for items in self.status_items.values())
|
||||||
|
self.status_label.setText(f"Checked {total} game entries across platforms.")
|
||||||
|
|
||||||
|
def _build_game_status(
|
||||||
|
self,
|
||||||
|
game_id: str,
|
||||||
|
game_name: str,
|
||||||
|
product: dict,
|
||||||
|
platform: InstallerPlatform,
|
||||||
|
metadata: MetadataStore,
|
||||||
|
parent_name: str = "",
|
||||||
|
) -> GameStatusInfo:
|
||||||
|
"""Build GameStatusInfo with per-language version tracking."""
|
||||||
|
platform_key = "windows" if platform == InstallerPlatform.WINDOWS else "linux"
|
||||||
|
installers = product.get("downloads", {}).get("installers", [])
|
||||||
|
|
||||||
|
lang_versions: dict[str, str] = {}
|
||||||
|
for inst in installers:
|
||||||
|
if inst.get("os") == platform_key:
|
||||||
|
lang = inst.get("language", "en")
|
||||||
|
version = inst.get("version") or ""
|
||||||
|
effective_langs = self.config.get_effective_languages(game_id)
|
||||||
|
if lang in effective_langs or not effective_langs:
|
||||||
|
lang_versions[lang] = version
|
||||||
|
|
||||||
|
record = metadata.get_game(game_id)
|
||||||
|
current_version = record.latest_version if record else ""
|
||||||
|
|
||||||
|
language_infos: list[LanguageVersionInfo] = []
|
||||||
|
for lang, latest_ver in lang_versions.items():
|
||||||
|
lang_status = self._determine_status_with_compare(current_version, latest_ver, game_name, lang)
|
||||||
|
language_infos.append(LanguageVersionInfo(
|
||||||
|
language=lang,
|
||||||
|
current_version=current_version,
|
||||||
|
latest_version=latest_ver,
|
||||||
|
status=lang_status,
|
||||||
|
))
|
||||||
|
|
||||||
|
if language_infos:
|
||||||
|
overall_status = self._worst_status(language_infos)
|
||||||
|
representative_latest = language_infos[0].latest_version
|
||||||
|
else:
|
||||||
|
overall_status = GameStatus.UNKNOWN
|
||||||
|
representative_latest = ""
|
||||||
|
|
||||||
|
all_same_version = len({lv.latest_version for lv in language_infos}) <= 1
|
||||||
|
|
||||||
|
bonus_available = 0
|
||||||
|
bonus_downloaded = 0
|
||||||
|
if self.config.get_effective_include_bonus(game_id) and language_infos:
|
||||||
|
bonus_items = self.api.get_bonus_content(game_id, product)
|
||||||
|
bonus_available = len(bonus_items)
|
||||||
|
if record:
|
||||||
|
bonus_downloaded = len(record.bonuses)
|
||||||
|
|
||||||
|
return GameStatusInfo(
|
||||||
|
game_id=game_id,
|
||||||
|
name=game_name,
|
||||||
|
current_version=current_version,
|
||||||
|
latest_version=representative_latest if all_same_version else "",
|
||||||
|
status=overall_status,
|
||||||
|
language_versions=language_infos,
|
||||||
|
last_checked=datetime.now(timezone.utc).isoformat(),
|
||||||
|
parent_name=parent_name,
|
||||||
|
bonus_available=bonus_available,
|
||||||
|
bonus_downloaded=bonus_downloaded,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_non_version(value: str) -> bool:
|
||||||
|
return value.strip().lower() in {"bonus", "n/a", "na", "none", ""}
|
||||||
|
|
||||||
|
def _determine_status_with_compare(
|
||||||
|
self, current: str, latest: str, game_name: str, language: str,
|
||||||
|
) -> GameStatus:
|
||||||
|
if not latest or self._is_non_version(latest):
|
||||||
|
return GameStatus.UNVERSIONED
|
||||||
|
if not current:
|
||||||
|
return GameStatus.NOT_DOWNLOADED
|
||||||
|
if self._is_non_version(current):
|
||||||
|
return GameStatus.NOT_DOWNLOADED
|
||||||
|
|
||||||
|
result = compare_versions(current, latest)
|
||||||
|
if result == CompareResult.EQUAL:
|
||||||
|
return GameStatus.UP_TO_DATE
|
||||||
|
elif result == CompareResult.OLDER:
|
||||||
|
return GameStatus.UPDATE_AVAILABLE
|
||||||
|
elif result == CompareResult.NEWER:
|
||||||
|
return GameStatus.UP_TO_DATE
|
||||||
|
else:
|
||||||
|
question = format_comparison_question(game_name, language, current, latest)
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Version Comparison",
|
||||||
|
question,
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
return GameStatus.UPDATE_AVAILABLE
|
||||||
|
return GameStatus.UP_TO_DATE
|
||||||
|
|
||||||
|
def _worst_status(self, lang_infos: list[LanguageVersionInfo]) -> GameStatus:
|
||||||
|
priority = [
|
||||||
|
GameStatus.UPDATE_AVAILABLE,
|
||||||
|
GameStatus.NOT_DOWNLOADED,
|
||||||
|
GameStatus.UNVERSIONED,
|
||||||
|
GameStatus.UNKNOWN,
|
||||||
|
GameStatus.UP_TO_DATE,
|
||||||
|
]
|
||||||
|
for status in priority:
|
||||||
|
if any(lv.status == status for lv in lang_infos):
|
||||||
|
return status
|
||||||
|
return GameStatus.UNKNOWN
|
||||||
|
|
||||||
|
def _has_downloadable_items(self) -> bool:
|
||||||
|
downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED}
|
||||||
|
for items in self.status_items.values():
|
||||||
|
for item in items:
|
||||||
|
if item.status in downloadable:
|
||||||
|
return True
|
||||||
|
if any(dlc.status in downloadable for dlc in item.dlcs):
|
||||||
|
return True
|
||||||
|
if item.bonus_available > item.bonus_downloaded:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _should_show(self, info: GameStatusInfo) -> bool:
|
||||||
|
if info.status in (GameStatus.UNKNOWN, GameStatus.UNVERSIONED):
|
||||||
|
return self.show_unknown_cb.isChecked()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _repopulate_current(self) -> None:
|
||||||
|
"""Repopulate only the currently visible platform tree."""
|
||||||
|
self._populate_tree(self._current_platform())
|
||||||
|
|
||||||
|
def _populate_tree(self, platform: InstallerPlatform) -> None:
|
||||||
|
tree = self._tree_for(platform)
|
||||||
|
tree.setSortingEnabled(False)
|
||||||
|
tree.clear()
|
||||||
|
|
||||||
|
for item in self.status_items[platform]:
|
||||||
|
visible_dlcs = [dlc for dlc in item.dlcs if self._should_show(dlc)]
|
||||||
|
if not self._should_show(item) and not visible_dlcs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
game_node = self._create_game_tree_item(item)
|
||||||
|
tree.addTopLevelItem(game_node)
|
||||||
|
|
||||||
|
for dlc in visible_dlcs:
|
||||||
|
dlc_node = self._create_game_tree_item(dlc)
|
||||||
|
game_node.addChild(dlc_node)
|
||||||
|
|
||||||
|
if visible_dlcs:
|
||||||
|
game_node.setExpanded(True)
|
||||||
|
|
||||||
|
tree.setSortingEnabled(True)
|
||||||
|
|
||||||
|
def _create_game_tree_item(self, info: GameStatusInfo) -> QTreeWidgetItem:
|
||||||
|
tree_item = QTreeWidgetItem()
|
||||||
|
tree_item.setText(COL_NAME, info.name)
|
||||||
|
tree_item.setData(COL_STATUS, Qt.ItemDataRole.UserRole, info.game_id)
|
||||||
|
|
||||||
|
has_different_versions = len({lv.latest_version for lv in info.language_versions}) > 1
|
||||||
|
|
||||||
|
if has_different_versions:
|
||||||
|
tree_item.setText(COL_CURRENT, info.current_version or "-")
|
||||||
|
tree_item.setText(COL_LATEST, "(per language)")
|
||||||
|
tree_item.setText(COL_STATUS, info.status_text)
|
||||||
|
tree_item.setText(COL_LANGS, "")
|
||||||
|
tree_item.setForeground(COL_STATUS, QBrush(QColor(STATUS_COLORS.get(info.status, "#000000"))))
|
||||||
|
|
||||||
|
for lv in info.language_versions:
|
||||||
|
lang_node = QTreeWidgetItem()
|
||||||
|
lang_node.setText(COL_NAME, f" {language_folder_name(lv.language)}")
|
||||||
|
lang_node.setText(COL_CURRENT, lv.current_version or "-")
|
||||||
|
lang_node.setText(COL_LATEST, lv.latest_version or "-")
|
||||||
|
lang_node.setText(COL_STATUS, GameStatusInfo(
|
||||||
|
game_id="", name="", current_version="", latest_version="", status=lv.status,
|
||||||
|
).status_text)
|
||||||
|
lang_node.setText(COL_LANGS, lv.language)
|
||||||
|
lang_node.setForeground(COL_STATUS, QBrush(QColor(STATUS_COLORS.get(lv.status, "#000000"))))
|
||||||
|
tree_item.addChild(lang_node)
|
||||||
|
|
||||||
|
tree_item.setExpanded(True)
|
||||||
|
else:
|
||||||
|
tree_item.setText(COL_CURRENT, info.current_version or "-")
|
||||||
|
latest = info.language_versions[0].latest_version if info.language_versions else "-"
|
||||||
|
tree_item.setText(COL_LATEST, latest or "-")
|
||||||
|
tree_item.setText(COL_STATUS, info.status_text)
|
||||||
|
langs = ", ".join(language_folder_name(lv.language) for lv in info.language_versions)
|
||||||
|
tree_item.setText(COL_LANGS, langs)
|
||||||
|
tree_item.setForeground(COL_STATUS, QBrush(QColor(STATUS_COLORS.get(info.status, "#000000"))))
|
||||||
|
|
||||||
|
if info.bonus_available > 0:
|
||||||
|
tree_item.setText(COL_BONUS, f"{info.bonus_downloaded}/{info.bonus_available}")
|
||||||
|
color = "#e65100" if info.bonus_downloaded < info.bonus_available else "#2e7d32"
|
||||||
|
tree_item.setForeground(COL_BONUS, QBrush(QColor(color)))
|
||||||
|
|
||||||
|
return tree_item
|
||||||
|
|
||||||
|
def download_updates(self) -> None:
|
||||||
|
"""Download updates for the currently visible platform."""
|
||||||
|
platform = self._current_platform()
|
||||||
|
base_path = self._base_path_for(platform)
|
||||||
|
if not base_path:
|
||||||
|
self.status_label.setText("No path configured for this platform.")
|
||||||
|
return
|
||||||
|
|
||||||
|
downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED}
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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.setMaximum(len(bonus_items_to_download))
|
||||||
|
self.progress_bar.setValue(0)
|
||||||
|
for game_name, bonus in bonus_items_to_download:
|
||||||
|
self.status_label.setText(f"Downloading bonus: {bonus.name}...")
|
||||||
|
b_metadata = MetadataStore(base_path)
|
||||||
|
b_downloader = InstallerDownloader(self.api, b_metadata)
|
||||||
|
if b_downloader.download_bonus(bonus, game_name):
|
||||||
|
bonus_count += 1
|
||||||
|
self.progress_bar.setValue(bonus_count)
|
||||||
|
|
||||||
|
self.progress_bar.setVisible(False)
|
||||||
|
self.check_btn.setEnabled(True)
|
||||||
|
parts = [f"Downloaded {completed} items"]
|
||||||
|
if bonus_count:
|
||||||
|
parts.append(f"{bonus_count} bonus files")
|
||||||
|
self.status_label.setText(f"{', '.join(parts)}. Run 'Check for Updates' to refresh.")
|
||||||
|
|
||||||
|
def _collect_bonus_downloads(
|
||||||
|
self, platform: InstallerPlatform, base_path: str,
|
||||||
|
) -> list[tuple[str, BonusContent]]:
|
||||||
|
"""Collect bonus content not yet downloaded for this platform."""
|
||||||
|
result: list[tuple[str, BonusContent]] = []
|
||||||
|
metadata = MetadataStore(base_path)
|
||||||
|
platform_key = "windows" if platform == InstallerPlatform.WINDOWS else "linux"
|
||||||
|
|
||||||
|
for item in self.status_items[platform]:
|
||||||
|
if item.bonus_available <= item.bonus_downloaded:
|
||||||
|
continue
|
||||||
|
|
||||||
|
record = metadata.get_game(item.game_id)
|
||||||
|
if not record or not record.installers:
|
||||||
|
product = self.api.get_product_info(item.game_id)
|
||||||
|
if not product:
|
||||||
|
continue
|
||||||
|
has_platform = any(
|
||||||
|
inst.get("os") == platform_key
|
||||||
|
for inst in product.get("downloads", {}).get("installers", [])
|
||||||
|
)
|
||||||
|
if not has_platform:
|
||||||
|
continue
|
||||||
|
|
||||||
|
downloaded_names = {b.name for b in record.bonuses} if record else set()
|
||||||
|
folder_name = item.parent_name if item.parent_name else item.name
|
||||||
|
for bonus in self.api.get_bonus_content(item.game_id):
|
||||||
|
if bonus.name not in downloaded_names:
|
||||||
|
result.append((folder_name, bonus))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
|
||||||
|
"""Open version management dialog for the clicked game."""
|
||||||
|
game_id = item.data(COL_STATUS, Qt.ItemDataRole.UserRole)
|
||||||
|
if not game_id:
|
||||||
|
return # language sub-row, no game_id stored
|
||||||
|
game_name = item.text(COL_NAME)
|
||||||
|
base_path = self._base_path_for(self._current_platform())
|
||||||
|
if not base_path:
|
||||||
|
return
|
||||||
|
metadata = MetadataStore(base_path)
|
||||||
|
dialog = GameVersionsDialog(game_id, game_name, metadata, self)
|
||||||
|
dialog.exec()
|
||||||
|
|
||||||
|
def prune_versions(self) -> None:
|
||||||
|
"""Remove old installer versions for the currently visible platform."""
|
||||||
|
platform = self._current_platform()
|
||||||
|
base_path = self._base_path_for(platform)
|
||||||
|
if not base_path:
|
||||||
|
self.status_label.setText("No path configured for this platform.")
|
||||||
|
return
|
||||||
|
|
||||||
|
keep = self.prune_keep_combo.currentIndex() + 1
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Prune Old Versions",
|
||||||
|
f"This will delete all but the {keep} most recent version(s) of each managed game "
|
||||||
|
f"in the {platform.value.capitalize()} path.\n\nContinue?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
metadata = MetadataStore(base_path)
|
||||||
|
total_deleted = sum(
|
||||||
|
len(metadata.prune_old_versions(game_id, keep_latest=keep))
|
||||||
|
for game_id in self.config.managed_game_ids
|
||||||
|
)
|
||||||
|
self.status_label.setText(f"Pruned {total_deleted} old version(s).")
|
||||||
81
src/version_compare.py
Normal file
81
src/version_compare.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Version comparison with fallback to user prompt for ambiguous cases."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class CompareResult(Enum):
|
||||||
|
OLDER = "older" # current < latest → update available
|
||||||
|
EQUAL = "equal" # current == latest → up to date
|
||||||
|
NEWER = "newer" # current > latest → somehow ahead
|
||||||
|
AMBIGUOUS = "ambiguous" # can't determine → ask user
|
||||||
|
|
||||||
|
|
||||||
|
# Pattern to strip GOG-specific suffixes like "(gog-3)", "(Galaxy)", etc.
|
||||||
|
_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 _parse_numeric_parts(version: str) -> list[int] | None:
|
||||||
|
"""Try to parse version as dot-separated integers. Returns None if not possible."""
|
||||||
|
normalized = _normalize(version)
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
parts = normalized.split(".")
|
||||||
|
try:
|
||||||
|
return [int(p) for p in parts]
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compare_versions(current: str, latest: str) -> CompareResult:
|
||||||
|
"""Compare two version strings.
|
||||||
|
|
||||||
|
Tries numeric comparison first. If both versions can be parsed as
|
||||||
|
dot-separated integers (after stripping GOG suffixes), compares numerically.
|
||||||
|
If versions are identical strings, returns EQUAL.
|
||||||
|
Otherwise returns AMBIGUOUS — caller should ask the user.
|
||||||
|
"""
|
||||||
|
if not current or not latest:
|
||||||
|
return CompareResult.AMBIGUOUS
|
||||||
|
|
||||||
|
# Exact string match
|
||||||
|
if current == latest:
|
||||||
|
return CompareResult.EQUAL
|
||||||
|
|
||||||
|
# Normalized string match
|
||||||
|
norm_current = _normalize(current)
|
||||||
|
norm_latest = _normalize(latest)
|
||||||
|
if norm_current == norm_latest:
|
||||||
|
return CompareResult.EQUAL
|
||||||
|
|
||||||
|
# Try numeric comparison
|
||||||
|
current_parts = _parse_numeric_parts(current)
|
||||||
|
latest_parts = _parse_numeric_parts(latest)
|
||||||
|
|
||||||
|
if current_parts is not None and latest_parts is not None:
|
||||||
|
# Pad shorter list with zeros for fair comparison
|
||||||
|
max_len = max(len(current_parts), len(latest_parts))
|
||||||
|
current_padded = current_parts + [0] * (max_len - len(current_parts))
|
||||||
|
latest_padded = latest_parts + [0] * (max_len - len(latest_parts))
|
||||||
|
|
||||||
|
if current_padded < latest_padded:
|
||||||
|
return CompareResult.OLDER
|
||||||
|
elif current_padded > latest_padded:
|
||||||
|
return CompareResult.NEWER
|
||||||
|
else:
|
||||||
|
return CompareResult.EQUAL
|
||||||
|
|
||||||
|
logger.debug(f"Ambiguous version comparison: '{current}' vs '{latest}'")
|
||||||
|
return CompareResult.AMBIGUOUS
|
||||||
|
|
||||||
|
|
||||||
|
def format_comparison_question(game_name: str, language: str, current: str, latest: str) -> str:
|
||||||
|
"""Format a human-readable question for ambiguous version comparison."""
|
||||||
|
return f"{game_name} [{language}]\n\nIs '{latest}' newer than '{current}'?"
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
141
tests/test_constants.py
Normal file
141
tests/test_constants.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""Tests for constants module."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import mock_open, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.constants import (
|
||||||
|
APP_NAME,
|
||||||
|
APP_TITLE,
|
||||||
|
APP_VERSION,
|
||||||
|
ENV_DEBUG,
|
||||||
|
get_debug_mode,
|
||||||
|
get_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_version()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_returns_string() -> None:
|
||||||
|
"""get_version() should return a string."""
|
||||||
|
assert isinstance(get_version(), str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_semver_format() -> None:
|
||||||
|
"""get_version() should return a semver-like string X.Y.Z."""
|
||||||
|
version = get_version()
|
||||||
|
assert re.match(r"^\d+\.\d+\.\d+", version), f"Not semver: {version!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_fallback_when_toml_missing(tmp_path: Path) -> None:
|
||||||
|
"""get_version() returns '0.0.0-unknown' when pyproject.toml and _version.py are both missing."""
|
||||||
|
missing = tmp_path / "nonexistent.toml"
|
||||||
|
with patch("src.constants._PYPROJECT_PATH", missing):
|
||||||
|
result = get_version()
|
||||||
|
# Either fallback _version.py exists (from previous run) or returns unknown
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_unknown_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""get_version() returns '0.0.0-unknown' when all sources are unavailable."""
|
||||||
|
missing = tmp_path / "nonexistent.toml"
|
||||||
|
monkeypatch.setattr("src.constants._PYPROJECT_PATH", missing)
|
||||||
|
|
||||||
|
# Patch _version import to also fail
|
||||||
|
with patch("src.constants.Path.write_text", side_effect=OSError):
|
||||||
|
with patch.dict("sys.modules", {"src._version": None}):
|
||||||
|
result = get_version()
|
||||||
|
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_debug_mode()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_debug_mode_returns_bool() -> None:
|
||||||
|
"""get_debug_mode() should always return a bool."""
|
||||||
|
assert isinstance(get_debug_mode(), bool)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_debug_mode_true(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""get_debug_mode() returns True when ENV_DEBUG=true."""
|
||||||
|
monkeypatch.setenv("ENV_DEBUG", "true")
|
||||||
|
assert get_debug_mode() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_debug_mode_true_variants(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""get_debug_mode() accepts '1' and 'yes' as truthy values."""
|
||||||
|
for value in ("1", "yes", "YES", "True", "TRUE"):
|
||||||
|
monkeypatch.setenv("ENV_DEBUG", value)
|
||||||
|
assert get_debug_mode() is True, f"Expected True for ENV_DEBUG={value!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_debug_mode_false(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""get_debug_mode() returns False when ENV_DEBUG=false."""
|
||||||
|
monkeypatch.setenv("ENV_DEBUG", "false")
|
||||||
|
assert get_debug_mode() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_debug_mode_false_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""get_debug_mode() returns False when ENV_DEBUG is not set."""
|
||||||
|
monkeypatch.delenv("ENV_DEBUG", raising=False)
|
||||||
|
assert get_debug_mode() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_debug_is_bool() -> None:
|
||||||
|
"""ENV_DEBUG should be a bool."""
|
||||||
|
assert isinstance(ENV_DEBUG, bool)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_version_is_string() -> None:
|
||||||
|
"""APP_VERSION should be a string."""
|
||||||
|
assert isinstance(APP_VERSION, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_version_semver_format() -> None:
|
||||||
|
"""APP_VERSION should follow semver format X.Y.Z."""
|
||||||
|
assert re.match(r"^\d+\.\d+\.\d+", APP_VERSION), f"Not semver: {APP_VERSION!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_name_value() -> None:
|
||||||
|
"""APP_NAME should be 'X4 SavEd'."""
|
||||||
|
assert APP_NAME == "X4 SavEd"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_title_contains_name_and_version() -> None:
|
||||||
|
"""APP_TITLE should contain APP_NAME and APP_VERSION."""
|
||||||
|
assert APP_NAME in APP_TITLE
|
||||||
|
assert APP_VERSION in APP_TITLE
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_title_dev_suffix_when_debug(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""APP_TITLE ends with '-DEV' when ENV_DEBUG is True."""
|
||||||
|
import importlib
|
||||||
|
import src.constants as consts
|
||||||
|
|
||||||
|
monkeypatch.setenv("ENV_DEBUG", "true")
|
||||||
|
monkeypatch.setattr(consts, "ENV_DEBUG", True)
|
||||||
|
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if True else "")
|
||||||
|
assert title.endswith("-DEV")
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_title_no_dev_suffix_when_not_debug(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""APP_TITLE does not end with '-DEV' when ENV_DEBUG is False."""
|
||||||
|
import src.constants as consts
|
||||||
|
|
||||||
|
monkeypatch.setattr(consts, "ENV_DEBUG", False)
|
||||||
|
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if False else "")
|
||||||
|
assert not title.endswith("-DEV")
|
||||||
Reference in New Issue
Block a user