forked from Boria138/PortProtonQt
		
	Compare commits
	
		
			17 Commits
		
	
	
		
			233dab1269
			...
			83076d3dfc
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 83076d3dfc | |||
| 04aaf68e36 | |||
| e91037708a | |||
| 1b743026c2 | |||
| 30b4cec4d1 | |||
| db68c9050c | |||
| 1a93d5b82c | |||
| cc0690cf9e | |||
| 809ba2c976 | |||
| 68c9636e10 | |||
| f0df1f89be | |||
| f25224b668 | |||
| 0cda47fdfd | |||
| 1a8c733580 | |||
| 2476bea32a | |||
| 1bbc95a5c1 | |||
| d12b801191 | 
| @@ -9,15 +9,21 @@ | ||||
| - Переводы в переопределениях (за подробностями в документацию) | ||||
| - Обложки и описания для всех автоинсталлов | ||||
| - Возможность указать ссылку для скачивания обложки в диалоге добавления игры | ||||
| - Интеграция с howlongtobeat.com | ||||
|  | ||||
| ### Changed | ||||
| - Оптимизированны обложки автоинсталлов | ||||
| - Папка custom_data исключена из сборки модуля для уменьшение его размера | ||||
| - Бейдж PortProton теперь открывает PortProtonDB | ||||
| - Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в gamescope сессии | ||||
| - Удалён аргумент `--session` так как тестирование gamescope сессии завершено | ||||
| - В контекстном меню игр без exe файла теперь отображается только пункт "Удалить из PortProton" | ||||
|  | ||||
| ### Fixed | ||||
| - Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси | ||||
| - Путь к portprotonqt-session-select в оверлее | ||||
| - Работа exiftool в AppImage | ||||
| - Открытие контекстного меню у игр без exe | ||||
|  | ||||
| ### Contributors | ||||
| - @Vector_null | ||||
|   | ||||
| @@ -51,11 +51,11 @@ pre-commit run --all-files | ||||
|  | ||||
| PortProtonQt использует код и зависимости от следующих проектов: | ||||
|  | ||||
| - [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html). | ||||
| - [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://opensource.org/licenses/MIT). | ||||
| - [PortProton 2.0](https://git.linux-gaming.ru/CastroFidel/PortProton_2.0) — библиотека для взаимодействия с PortProton, лицензия [MIT](https://opensource.org/licenses/MIT). | ||||
| - [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE). | ||||
| - [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE). | ||||
| - [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md). | ||||
|  | ||||
| Полный текст лицензий см. в файлах [LICENSE](LICENSE), [LICENSE-icoextract](documentation/licenses/icoextract), [LICENSE-portproton](documentation/licenses/portproton), [LICENSE-legendary](documentation/licenses/legendary). | ||||
| Полный текст лицензий см. в файле [LICENSE](LICENSE). | ||||
|  | ||||
| > [!WARNING] | ||||
| > Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована | ||||
|   | ||||
							
								
								
									
										5
									
								
								TODO.md
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								TODO.md
									
									
									
									
									
								
							| @@ -17,7 +17,6 @@ | ||||
| - [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода | ||||
| - [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки) | ||||
| - [X] Избавиться от вызовов yad | ||||
| - [X] Реализовать собственный механизм запрета ухода в спящий режим вместо использования механизма PortProton (оставлено для [PortProton 2.0](https://github.com/Castro-Fidel/PortProton_2.0)) | ||||
| - [X] Реализовать собственный системный трей вместо использования трея PortProton | ||||
| - [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.) | ||||
| - [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту) | ||||
| @@ -42,6 +41,7 @@ | ||||
| - [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql) | ||||
| - [X] Добавить на карточку бейдж, указывающий, что игра из Steam | ||||
| - [X] Добавить поддержку версий Steam для Flatpak и Snap | ||||
| - [ ] Реализовать добавление игры как сторонней в Steam без перезапуска | ||||
| - [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся | ||||
| - [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад» | ||||
| - [X] Добавить перевод через gettext [Документация](documentation/localization_guide) | ||||
| @@ -57,13 +57,12 @@ | ||||
| - [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме | ||||
| - [ ] Добавить поддержку GOG (?) | ||||
| - [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант) | ||||
| - [ ] Добавить данные с HowLongToBeat на страницу с деталями игры (?) | ||||
| - [X] Добавить данные с HowLongToBeat на страницу с деталями игры | ||||
| - [X] Добавить виброотдачу на геймпаде при запуске игры | ||||
| - [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63) | ||||
| - [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)) | ||||
| - [X] Скопировать логику управления с D-pad на стрелки с клавиатуры | ||||
| - [ ] Доделать светлую тему | ||||
| - [ ] Добавить подсказки к управлению с геймпада | ||||
| - [ ] Добавить загрузку звуков в темы например для добавления звука запуска в тему и тд | ||||
| - [X] Добавить миниатюры к выбору файлов в диалоге добавления игры | ||||
| - [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| version: 1 | ||||
|  | ||||
| script: | ||||
|   # 1) чистим старый AppDir | ||||
|   - rm -rf AppDir || true | ||||
| @@ -17,10 +16,31 @@ script: | ||||
|   - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*} | ||||
|   - shopt -s extglob | ||||
|   - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*) | ||||
|  | ||||
| AppDir: | ||||
|   path: ./AppDir | ||||
|  | ||||
|   after_bundle: | ||||
|     # Документация, справка, примеры | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/man || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/doc || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/doc-base || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/info || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/help || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/gtk-doc || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/devhelp || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/examples || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/pkgconfig || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/bash-completion || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/pixmaps || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/mime || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/metainfo || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/include || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true | ||||
|     # Статика и отладка | ||||
|     - find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true | ||||
|     # Strip ELF бинарников (исключая Python extensions) | ||||
|     - "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true" | ||||
|     # Удаление пустых папок | ||||
|     - find $TARGET_APPDIR -type d -empty -delete || true | ||||
|   app_info: | ||||
|     id: ru.linux_gaming.PortProtonQt | ||||
|     name: PortProtonQt | ||||
| @@ -28,15 +48,13 @@ AppDir: | ||||
|     version: 0.1.3 | ||||
|     exec: usr/bin/python3 | ||||
|     exec_args: "-m portprotonqt.app $@" | ||||
|  | ||||
|   apt: | ||||
|     arch: amd64 | ||||
|     sources: | ||||
|       - sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse' | ||||
|         key_url: 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x871920d1991bc93c' | ||||
|  | ||||
|     include: | ||||
|       - python3 | ||||
|       - python3-minimal | ||||
|       - python3-pkg-resources | ||||
|       - libopengl0 | ||||
|       - libk5crypto3 | ||||
| @@ -45,13 +63,23 @@ AppDir: | ||||
|       - libxcb-cursor0 | ||||
|       - libimage-exiftool-perl | ||||
|       - xdg-utils | ||||
|     exclude: [] | ||||
|  | ||||
|     exclude: | ||||
|       # Документация и man-страницы | ||||
|       - "*-doc" | ||||
|       - "*-man" | ||||
|       - manpages | ||||
|       - mandb | ||||
|       # Статические библиотеки | ||||
|       - "*-dev" | ||||
|       - "*-static" | ||||
|       # Дебаг-символы | ||||
|       - "*-dbg" | ||||
|       - "*-dbgsym" | ||||
|   runtime: | ||||
|     env: | ||||
|       PYTHONHOME: '${APPDIR}/usr' | ||||
|       PYTHONPATH: '${APPDIR}/usr/local/lib/python3.10/dist-packages' | ||||
|  | ||||
|       PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34' | ||||
| AppImage: | ||||
|   sign-key: None | ||||
|   arch: x86_64 | ||||
|   | ||||
| @@ -9,7 +9,7 @@ _portprotonqt() { | ||||
|     esac | ||||
|  | ||||
|     if [[ "$cur" == -* ]]; then | ||||
|         COMPREPLY=( $( compgen -W '--fullscreen --session' -- "$cur" ) ) | ||||
|         COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) ) | ||||
|         return 0 | ||||
|     fi | ||||
|  | ||||
|   | ||||
| @@ -20,9 +20,9 @@ Current translation status: | ||||
|  | ||||
| | Locale | Progress | Translated | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 194 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 194 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 of 194 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 197 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 197 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 of 197 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -20,9 +20,9 @@ | ||||
|  | ||||
| | Локаль | Прогресс | Переведено | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 194 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 194 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 из 194 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 197 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 197 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 из 197 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,4 @@ | ||||
| import sys | ||||
| import os | ||||
| import subprocess | ||||
| from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo | ||||
| from PySide6.QtWidgets import QApplication | ||||
| from PySide6.QtGui import QIcon | ||||
| @@ -35,13 +33,6 @@ def main(): | ||||
|  | ||||
|     window = MainWindow() | ||||
|  | ||||
|     if args.session: | ||||
|         gamescope_cmd = os.getenv("GAMESCOPE_CMD", "gamescope -f --xwayland-count 2") | ||||
|         cmd = f"{gamescope_cmd} -- portprotonqt" | ||||
|         logger.info(f"Executing: {cmd}") | ||||
|         subprocess.Popen(cmd, shell=True) | ||||
|         sys.exit(0) | ||||
|  | ||||
|     if args.fullscreen: | ||||
|         logger.info("Launching in fullscreen mode due to --fullscreen flag") | ||||
|         save_fullscreen_config(True) | ||||
|   | ||||
| @@ -13,9 +13,4 @@ def parse_args(): | ||||
|         action="store_true", | ||||
|         help="Запустить приложение в полноэкранном режиме и сохранить эту настройку" | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--session", | ||||
|         action="store_true", | ||||
|         help="Запустить приложение с использованием gamescope" | ||||
|     ) | ||||
|     return parser.parse_args() | ||||
|   | ||||
| @@ -148,10 +148,7 @@ class ContextMenuManager: | ||||
|                 return False | ||||
|             current_exe = os.path.basename(exe_path) | ||||
|  | ||||
|         # Check if the current_exe matches the target_exe in MainWindow | ||||
|         if hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe: | ||||
|             return True | ||||
|         return False | ||||
|         return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe | ||||
|  | ||||
|     def show_context_menu(self, game_card, pos: QPoint): | ||||
|         """ | ||||
| @@ -161,7 +158,6 @@ class ContextMenuManager: | ||||
|             game_card: The GameCard instance requesting the context menu. | ||||
|             pos: The position (in widget coordinates) where the menu should appear. | ||||
|         """ | ||||
|  | ||||
|         def get_safe_icon(icon_name: str) -> QIcon: | ||||
|             icon = self.theme_manager.get_icon(icon_name) | ||||
|             if isinstance(icon, QIcon): | ||||
| @@ -173,7 +169,18 @@ class ContextMenuManager: | ||||
|         menu = QMenu(self.parent) | ||||
|         menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE) | ||||
|  | ||||
|         # Check if the game is running | ||||
|         # For non-Steam and non-Epic games, check if exe exists | ||||
|         if game_card.game_source not in ("steam", "epic"): | ||||
|             exec_line = self._get_exec_line(game_card.name, game_card.exec_line) | ||||
|             exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None | ||||
|             if not exe_path: | ||||
|                 # Show only "Delete from PortProton" if no valid exe | ||||
|                 delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton")) | ||||
|                 delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line)) | ||||
|                 menu.exec(game_card.mapToGlobal(pos)) | ||||
|                 return | ||||
|  | ||||
|         # Normal menu for games with valid exe or from Steam/Epic | ||||
|         is_running = self._is_game_running(game_card) | ||||
|         action_text = _("Stop Game") if is_running else _("Launch Game") | ||||
|         action_icon = "stop" if is_running else "play" | ||||
| @@ -697,15 +704,12 @@ Icon={icon_path} | ||||
|             return None | ||||
|         return exec_line | ||||
|  | ||||
|     def _parse_exe_path(self, exec_line, game_name): | ||||
|     def _parse_exe_path(self, exec_line: str, game_name: str) -> str | None: | ||||
|         """Parse the executable path from exec_line.""" | ||||
|         try: | ||||
|             entry_exec_split = shlex.split(exec_line) | ||||
|             if not entry_exec_split: | ||||
|                 self.signals.show_warning_dialog.emit( | ||||
|                     _("Error"), | ||||
|                     _("Invalid executable command: {exec_line}").format(exec_line=exec_line) | ||||
|                 ) | ||||
|                 logger.debug("Invalid executable command for '%s': %s", game_name, exec_line) | ||||
|                 return None | ||||
|             if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3: | ||||
|                 exe_path = entry_exec_split[2] | ||||
| @@ -714,17 +718,11 @@ Icon={icon_path} | ||||
|             else: | ||||
|                 exe_path = entry_exec_split[-1] | ||||
|             if not exe_path or not os.path.exists(exe_path): | ||||
|                 self.signals.show_warning_dialog.emit( | ||||
|                     _("Error"), | ||||
|                     _("Executable not found: {path}").format(path=exe_path or "None") | ||||
|                 ) | ||||
|                 logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None") | ||||
|                 return None | ||||
|             return exe_path | ||||
|         except Exception as e: | ||||
|             self.signals.show_warning_dialog.emit( | ||||
|                 _("Error"), | ||||
|                 _("Failed to parse executable: {error}").format(error=str(e)) | ||||
|             ) | ||||
|             logger.debug("Failed to parse executable for '%s': %s", game_name, e) | ||||
|             return None | ||||
|  | ||||
|     def _remove_file(self, file_path, error_message, success_message, game_name, location=""): | ||||
|   | ||||
| @@ -1,71 +1,37 @@ | ||||
| import orjson | ||||
| import re | ||||
| import os | ||||
| from dataclasses import dataclass, field | ||||
| from enum import Enum | ||||
| from typing import Any | ||||
| from difflib import SequenceMatcher | ||||
| 
 | ||||
| from threading import Thread | ||||
| import requests | ||||
| from bs4 import BeautifulSoup, Tag | ||||
| from portprotonqt.config_utils import read_proxy_config | ||||
| 
 | ||||
| 
 | ||||
| class SearchModifiers(Enum): | ||||
|     """Модификаторы поиска для фильтрации результатов.""" | ||||
|     NONE = "" | ||||
|     ONLY_DLC = "only_dlc" | ||||
|     ONLY_MODS = "only_mods" | ||||
|     ONLY_HACKS = "only_hacks" | ||||
|     HIDE_DLC = "hide_dlc" | ||||
| 
 | ||||
| from portprotonqt.time_utils import format_playtime | ||||
| from PySide6.QtCore import QObject, Signal | ||||
| 
 | ||||
| @dataclass | ||||
| class GameEntry: | ||||
|     """Информация об игре из HowLongToBeat.""" | ||||
|     # Основная информация | ||||
|     game_id: int = -1 | ||||
|     game_name: str | None = None | ||||
|     game_alias: str | None = None | ||||
|     game_type: str | None = None | ||||
|     game_image_url: str | None = None | ||||
|     game_web_link: str | None = None | ||||
|     review_score: float | None = None | ||||
|     developer: str | None = None | ||||
|     platforms: list[str] = field(default_factory=list) | ||||
|     release_year: int | None = None | ||||
|     similarity: float = -1.0 | ||||
| 
 | ||||
|     # Времена прохождения (в часах) | ||||
|     main_story: float | None = None | ||||
|     main_extra: float | None = None | ||||
|     completionist: float | None = None | ||||
|     all_styles: float | None = None | ||||
|     coop_time: float | None = None | ||||
|     multiplayer_time: float | None = None | ||||
| 
 | ||||
|     # Флаги сложности | ||||
|     has_single_player: bool = False | ||||
|     has_coop: bool = False | ||||
|     has_multiplayer: bool = False | ||||
|     has_combined_complexity: bool = False | ||||
| 
 | ||||
|     # Исходные данные JSON | ||||
|     similarity: float = -1.0 | ||||
|     raw_data: dict[str, Any] = field(default_factory=dict) | ||||
| 
 | ||||
| 
 | ||||
| @dataclass | ||||
| class SearchConfig: | ||||
|     """Конфигурация для поиска.""" | ||||
|     api_key: str | None = None | ||||
|     search_url: str | None = None | ||||
| 
 | ||||
| 
 | ||||
| class APIKeyExtractor: | ||||
|     """Извлекает API ключ и URL поиска из скриптов сайта.""" | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def extract_from_script(script_content: str) -> SearchConfig: | ||||
|         """Извлекает конфигурацию из содержимого скрипта.""" | ||||
|         config = SearchConfig() | ||||
|         config.api_key = APIKeyExtractor._extract_api_key(script_content) | ||||
|         config.search_url = APIKeyExtractor._extract_search_url(script_content, config.api_key) | ||||
| @@ -73,53 +39,40 @@ class APIKeyExtractor: | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def _extract_api_key(script_content: str) -> str | None: | ||||
|         """Извлекает API ключ из скрипта.""" | ||||
|         # Паттерн для поиска user ID | ||||
|         user_id_pattern = r'users\s*:\s*{\s*id\s*:\s*"([^"]+)"' | ||||
|         matches = re.findall(user_id_pattern, script_content) | ||||
|         if matches: | ||||
|             return ''.join(matches) | ||||
| 
 | ||||
|         # Паттерн для поиска конкатенированного API ключа | ||||
|         concat_pattern = r'\/api\/\w+\/"(?:\.concat\("[^"]*"\))+' | ||||
|         matches = re.findall(concat_pattern, script_content) | ||||
|         if matches: | ||||
|             parts = str(matches).split('.concat') | ||||
|             cleaned_parts = [re.sub(r'["\(\)\[\]\']', '', part) for part in parts[1:]] | ||||
|             return ''.join(cleaned_parts) | ||||
| 
 | ||||
|         return None | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def _extract_search_url(script_content: str, api_key: str | None) -> str | None: | ||||
|         """Извлекает URL поиска из скрипта.""" | ||||
|         if not api_key: | ||||
|             return None | ||||
| 
 | ||||
|         pattern = re.compile( | ||||
|             r'fetch\(\s*["\'](\/api\/[^"\']*)["\']' | ||||
|             r'((?:\s*\.concat\(\s*["\']([^"\']*)["\']\s*\))+)' | ||||
|             r'\s*,', | ||||
|             re.DOTALL | ||||
|         ) | ||||
| 
 | ||||
|         for match in pattern.finditer(script_content): | ||||
|             endpoint = match.group(1) | ||||
|             concat_calls = match.group(2) | ||||
|             concat_strings = re.findall(r'\.concat\(\s*["\']([^"\']*)["\']\s*\)', concat_calls) | ||||
|             concatenated_str = ''.join(concat_strings) | ||||
| 
 | ||||
|             if concatenated_str == api_key: | ||||
|                 return endpoint | ||||
| 
 | ||||
|         return None | ||||
| 
 | ||||
| 
 | ||||
| class HTTPClient: | ||||
|     """HTTP клиент для работы с API HowLongToBeat.""" | ||||
| 
 | ||||
|     BASE_URL = 'https://howlongtobeat.com/' | ||||
|     GAME_URL = BASE_URL + "game" | ||||
|     SEARCH_URL = BASE_URL + "api/s/" | ||||
| 
 | ||||
|     def __init__(self, timeout: int = 60): | ||||
| @@ -129,35 +82,23 @@ class HTTPClient: | ||||
|             'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', | ||||
|             'referer': self.BASE_URL | ||||
|         }) | ||||
|         # Apply proxy settings from config | ||||
|         proxy_config = read_proxy_config() | ||||
|         if proxy_config: | ||||
|             self.session.proxies.update(proxy_config) | ||||
| 
 | ||||
|     def get_search_config(self, parse_all_scripts: bool = False) -> SearchConfig | None: | ||||
|         """Получает конфигурацию поиска с главной страницы.""" | ||||
|         try: | ||||
|             response = self.session.get(self.BASE_URL, timeout=self.timeout) | ||||
|             response.raise_for_status() | ||||
|             soup = BeautifulSoup(response.text, 'html.parser') | ||||
|             scripts = soup.find_all('script', src=True) | ||||
| 
 | ||||
|             # Filter for Tag objects and ensure src is a string | ||||
|             if parse_all_scripts: | ||||
|                 script_urls = [] | ||||
|                 for script in scripts: | ||||
|                     if isinstance(script, Tag): | ||||
|                         src = script.get('src') | ||||
|                         if src is not None and isinstance(src, str): | ||||
|             script_urls = [] | ||||
|             for script in scripts: | ||||
|                 if isinstance(script, Tag): | ||||
|                     src = script.get('src') | ||||
|                     if src is not None and isinstance(src, str): | ||||
|                         if parse_all_scripts or '_app-' in src: | ||||
|                             script_urls.append(src) | ||||
|             else: | ||||
|                 script_urls = [] | ||||
|                 for script in scripts: | ||||
|                     if isinstance(script, Tag): | ||||
|                         src = script.get('src') | ||||
|                         if src is not None and isinstance(src, str) and '_app-' in src: | ||||
|                             script_urls.append(src) | ||||
| 
 | ||||
|             for script_url in script_urls: | ||||
|                 full_url = self.BASE_URL + script_url | ||||
|                 script_response = self.session.get(full_url, timeout=self.timeout) | ||||
| @@ -169,28 +110,21 @@ class HTTPClient: | ||||
|             pass | ||||
|         return None | ||||
| 
 | ||||
|     def search_games(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE, | ||||
|                     page: int = 1, config: SearchConfig | None = None) -> str | None: | ||||
|         """Выполняет поиск игр.""" | ||||
|     def search_games(self, game_name: str, page: int = 1, config: SearchConfig | None = None) -> str | None: | ||||
|         if not config: | ||||
|             config = self.get_search_config() | ||||
|             if not config: | ||||
|                 config = self.get_search_config(parse_all_scripts=True) | ||||
| 
 | ||||
|         if not config or not config.api_key: | ||||
|             return None | ||||
| 
 | ||||
|         search_url = self.SEARCH_URL | ||||
|         if config.search_url: | ||||
|             search_url = self.BASE_URL + config.search_url.lstrip('/') | ||||
| 
 | ||||
|         payload = self._build_search_payload(game_name, search_modifiers, page, config) | ||||
|         payload = self._build_search_payload(game_name, page, config) | ||||
|         headers = { | ||||
|             'content-type': 'application/json', | ||||
|             'accept': '*/*' | ||||
|         } | ||||
| 
 | ||||
|         # Попытка с API ключом в URL | ||||
|         try: | ||||
|             response = self.session.post( | ||||
|                 search_url + config.api_key, | ||||
| @@ -202,8 +136,6 @@ class HTTPClient: | ||||
|                 return response.text | ||||
|         except requests.RequestException: | ||||
|             pass | ||||
| 
 | ||||
|         # Попытка с API ключом в payload | ||||
|         try: | ||||
|             response = self.session.post( | ||||
|                 search_url, | ||||
| @@ -215,37 +147,14 @@ class HTTPClient: | ||||
|                 return response.text | ||||
|         except requests.RequestException: | ||||
|             pass | ||||
| 
 | ||||
|         return None | ||||
| 
 | ||||
|     def get_game_title(self, game_id: int) -> str | None: | ||||
|         """Получает название игры по ID.""" | ||||
|         try: | ||||
|             params = {'id': str(game_id)} | ||||
|             response = self.session.get(self.GAME_URL, params=params, timeout=self.timeout) | ||||
|             response.raise_for_status() | ||||
| 
 | ||||
|             soup = BeautifulSoup(response.text, 'html.parser') | ||||
|             title_tag = soup.title | ||||
| 
 | ||||
|             if title_tag and title_tag.string: | ||||
|                 # Обрезаем стандартные части заголовка | ||||
|                 title = title_tag.string[12:-17].strip() | ||||
|                 return title | ||||
| 
 | ||||
|         except requests.RequestException: | ||||
|             pass | ||||
| 
 | ||||
|         return None | ||||
| 
 | ||||
|     def _build_search_payload(self, game_name: str, search_modifiers: SearchModifiers, | ||||
|                              page: int, config: SearchConfig) -> dict[str, Any]: | ||||
|         """Строит payload для поискового запроса.""" | ||||
|     def _build_search_payload(self, game_name: str, page: int, config: SearchConfig) -> dict[str, Any]: | ||||
|         payload = { | ||||
|             'searchType': "games", | ||||
|             'searchTerms': game_name.split(), | ||||
|             'searchPage': page, | ||||
|             'size': 20, | ||||
|             'size': 1,  # Limit to 1 result | ||||
|             'searchOptions': { | ||||
|                 'games': { | ||||
|                     'userId': 0, | ||||
| @@ -260,7 +169,7 @@ class HTTPClient: | ||||
|                         "difficulty": "" | ||||
|                     }, | ||||
|                     'rangeYear': {'max': "", 'min': ""}, | ||||
|                     'modifier': search_modifiers.value, | ||||
|                     'modifier': ""  # Hardcoded to empty string for SearchModifiers.NONE | ||||
|                 }, | ||||
|                 'users': {'sortCategory': "postcount"}, | ||||
|                 'lists': {'sortCategory': "follows"}, | ||||
| @@ -268,194 +177,195 @@ class HTTPClient: | ||||
|                 'sort': 0, | ||||
|                 'randomizer': 0 | ||||
|             }, | ||||
|             'useCache': True | ||||
|             'useCache': True, | ||||
|             'fields': ["game_id", "game_name", "comp_main", "comp_plus", "comp_100"]  # Request only needed fields | ||||
|         } | ||||
| 
 | ||||
|         if config.api_key: | ||||
|             payload['searchOptions']['users']['id'] = config.api_key | ||||
| 
 | ||||
|         return payload | ||||
| 
 | ||||
| 
 | ||||
| class ResultParser: | ||||
|     """Парсер результатов поиска.""" | ||||
| 
 | ||||
|     IMAGE_URL_PREFIX = "https://howlongtobeat.com/games/" | ||||
|     GAME_URL_PREFIX = "https://howlongtobeat.com/game/" | ||||
| 
 | ||||
|     def __init__(self, search_query: str, minimum_similarity: float = 0.4, | ||||
|                  case_sensitive: bool = True, auto_filter_times: bool = False): | ||||
|     def __init__(self, search_query: str, minimum_similarity: float = 0.4, case_sensitive: bool = True): | ||||
|         self.search_query = search_query | ||||
|         self.minimum_similarity = minimum_similarity | ||||
|         self.case_sensitive = case_sensitive | ||||
|         self.auto_filter_times = auto_filter_times | ||||
|         self.search_numbers = self._extract_numbers(search_query) | ||||
| 
 | ||||
|     def parse_results(self, json_response: str, target_game_id: int | None = None) -> list[GameEntry]: | ||||
|         """Парсит JSON ответ и возвращает список игр.""" | ||||
|         try: | ||||
|             data = orjson.loads(json_response) | ||||
|             games = [] | ||||
| 
 | ||||
|             for game_data in data.get("data", []): | ||||
|             # Only process the first result | ||||
|             if data.get("data"): | ||||
|                 game_data = data["data"][0] | ||||
|                 game = self._parse_game_entry(game_data) | ||||
| 
 | ||||
|                 if target_game_id is not None: | ||||
|                     if game.game_id == target_game_id: | ||||
|                         games.append(game) | ||||
|                 elif self.minimum_similarity == 0.0 or game.similarity >= self.minimum_similarity: | ||||
|                     games.append(game) | ||||
| 
 | ||||
|             return games | ||||
| 
 | ||||
|         except (orjson.JSONDecodeError, KeyError): | ||||
|         except (orjson.JSONDecodeError, KeyError, IndexError): | ||||
|             return [] | ||||
| 
 | ||||
|     def _parse_game_entry(self, game_data: dict[str, Any]) -> GameEntry: | ||||
|         """Парсит данные одной игры.""" | ||||
|         game = GameEntry() | ||||
| 
 | ||||
|         # Основная информация | ||||
|         game.game_id = game_data.get("game_id", -1) | ||||
|         game.game_name = game_data.get("game_name") | ||||
|         game.game_alias = game_data.get("game_alias") | ||||
|         game.game_type = game_data.get("game_type") | ||||
|         game.review_score = game_data.get("review_score") | ||||
|         game.developer = game_data.get("profile_dev") | ||||
|         game.release_year = game_data.get("release_world") | ||||
|         game.raw_data = game_data | ||||
| 
 | ||||
|         # URL изображения | ||||
|         if "game_image" in game_data: | ||||
|             game.game_image_url = self.IMAGE_URL_PREFIX + game_data["game_image"] | ||||
| 
 | ||||
|         # Ссылка на игру | ||||
|         game.game_web_link = f"{self.GAME_URL_PREFIX}{game.game_id}" | ||||
| 
 | ||||
|         # Платформы | ||||
|         if "profile_platform" in game_data: | ||||
|             game.platforms = game_data["profile_platform"].split(", ") | ||||
| 
 | ||||
|         # Времена прохождения (конвертация из секунд в часы) | ||||
|         time_fields = [ | ||||
|             ("comp_main", "main_story"), | ||||
|             ("comp_plus", "main_extra"), | ||||
|             ("comp_100", "completionist"), | ||||
|             ("comp_all", "all_styles"), | ||||
|             ("invested_co", "coop_time"), | ||||
|             ("invested_mp", "multiplayer_time") | ||||
|             ("comp_100", "completionist") | ||||
|         ] | ||||
| 
 | ||||
|         for json_field, attr_name in time_fields: | ||||
|             if json_field in game_data: | ||||
|                 time_hours = round(game_data[json_field] / 3600, 2) | ||||
|                 setattr(game, attr_name, time_hours) | ||||
| 
 | ||||
|         # Флаги сложности | ||||
|         game.has_combined_complexity = bool(game_data.get("comp_lvl_combine", 0)) | ||||
|         game.has_single_player = bool(game_data.get("comp_lvl_sp", 0)) | ||||
|         game.has_coop = bool(game_data.get("comp_lvl_co", 0)) | ||||
|         game.has_multiplayer = bool(game_data.get("comp_lvl_mp", 0)) | ||||
| 
 | ||||
|         # Автофильтрация времен | ||||
|         if self.auto_filter_times: | ||||
|             if not game.has_single_player: | ||||
|                 game.main_story = None | ||||
|                 game.main_extra = None | ||||
|                 game.completionist = None | ||||
|                 game.all_styles = None | ||||
|             if not game.has_coop: | ||||
|                 game.coop_time = None | ||||
|             if not game.has_multiplayer: | ||||
|                 game.multiplayer_time = None | ||||
| 
 | ||||
|         # Вычисление similarity | ||||
|         game.similarity = self._calculate_similarity(game) | ||||
| 
 | ||||
|         return game | ||||
| 
 | ||||
|     def _calculate_similarity(self, game: GameEntry) -> float: | ||||
|         """Вычисляет similarity между поисковым запросом и игрой.""" | ||||
|         name_similarity = self._compare_strings(self.search_query, game.game_name) | ||||
|         alias_similarity = self._compare_strings(self.search_query, game.game_alias) | ||||
| 
 | ||||
|         return max(name_similarity, alias_similarity) | ||||
|         return self._compare_strings(self.search_query, game.game_name) | ||||
| 
 | ||||
|     def _compare_strings(self, a: str | None, b: str | None) -> float: | ||||
|         """Сравнивает две строки и возвращает коэффициент similarity.""" | ||||
|         if not a or not b: | ||||
|             return 0.0 | ||||
| 
 | ||||
|         if self.case_sensitive: | ||||
|             similarity = SequenceMatcher(None, a, b).ratio() | ||||
|         else: | ||||
|             similarity = SequenceMatcher(None, a.lower(), b.lower()).ratio() | ||||
| 
 | ||||
|         # Штраф за отсутствие чисел из оригинального запроса | ||||
|         if self.search_numbers and not self._contains_numbers(b, self.search_numbers): | ||||
|             similarity -= 0.1 | ||||
| 
 | ||||
|         return max(0.0, similarity) | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def _extract_numbers(text: str) -> list[str]: | ||||
|         """Извлекает числа из текста.""" | ||||
|         return [word for word in text.split() if word.isdigit()] | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def _contains_numbers(text: str, numbers: list[str]) -> bool: | ||||
|         """Проверяет, содержит ли текст указанные числа.""" | ||||
|         if not numbers: | ||||
|             return True | ||||
| 
 | ||||
|         cleaned_text = re.sub(r'([^\s\w]|_)+', '', text) | ||||
|         text_numbers = [word for word in cleaned_text.split() if word.isdigit()] | ||||
| 
 | ||||
|         return any(num in text_numbers for num in numbers) | ||||
| 
 | ||||
| def get_cache_dir(): | ||||
|     """Возвращает путь к каталогу кэша, создаёт его при необходимости.""" | ||||
|     xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) | ||||
|     cache_dir = os.path.join(xdg_cache_home, "PortProtonQt") | ||||
|     os.makedirs(cache_dir, exist_ok=True) | ||||
|     return cache_dir | ||||
| 
 | ||||
| class HowLongToBeat: | ||||
| class HowLongToBeat(QObject): | ||||
|     """Основной класс для работы с API HowLongToBeat.""" | ||||
|     searchCompleted = Signal(list) | ||||
| 
 | ||||
|     def __init__(self, minimum_similarity: float = 0.4, auto_filter_times: bool = False, | ||||
|                  timeout: int = 60): | ||||
|     def __init__(self, minimum_similarity: float = 0.4, timeout: int = 60, parent=None): | ||||
|         super().__init__(parent) | ||||
|         self.minimum_similarity = minimum_similarity | ||||
|         self.auto_filter_times = auto_filter_times | ||||
|         self.http_client = HTTPClient(timeout) | ||||
|         self.cache_dir = get_cache_dir() | ||||
| 
 | ||||
|     def search(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE, | ||||
|                case_sensitive: bool = True) -> list[GameEntry] | None: | ||||
|         """Ищет игры по названию.""" | ||||
|     def _get_cache_file_path(self, game_name: str) -> str: | ||||
|         """Возвращает путь к файлу кэша для заданного имени игры.""" | ||||
|         safe_game_name = re.sub(r'[^\w\s-]', '', game_name).replace(' ', '_').lower() | ||||
|         cache_file = f"hltb_{safe_game_name}.json" | ||||
|         return os.path.join(self.cache_dir, cache_file) | ||||
| 
 | ||||
|     def _load_from_cache(self, game_name: str) -> str | None: | ||||
|         """Пытается загрузить данные из кэша, если они существуют.""" | ||||
|         cache_file = self._get_cache_file_path(game_name) | ||||
|         try: | ||||
|             if os.path.exists(cache_file): | ||||
|                 with open(cache_file, 'rb') as f: | ||||
|                     return f.read().decode('utf-8') | ||||
|         except (OSError, UnicodeDecodeError): | ||||
|             pass | ||||
|         return None | ||||
| 
 | ||||
|     def _save_to_cache(self, game_name: str, json_response: str): | ||||
|         """Сохраняет данные в кэш, храня только первую игру и необходимые поля.""" | ||||
|         cache_file = self._get_cache_file_path(game_name) | ||||
|         try: | ||||
|             # Парсим JSON и берем только первую игру | ||||
|             data = orjson.loads(json_response) | ||||
|             if data.get("data"): | ||||
|                 first_game = data["data"][0] | ||||
|                 simplified_data = { | ||||
|                     "data": [{ | ||||
|                         "game_id": first_game.get("game_id", -1), | ||||
|                         "game_name": first_game.get("game_name"), | ||||
|                         "comp_main": first_game.get("comp_main", 0), | ||||
|                         "comp_plus": first_game.get("comp_plus", 0), | ||||
|                         "comp_100": first_game.get("comp_100", 0) | ||||
|                     }] | ||||
|                 } | ||||
|                 with open(cache_file, 'wb') as f: | ||||
|                     f.write(orjson.dumps(simplified_data)) | ||||
|         except (OSError, orjson.JSONDecodeError, IndexError): | ||||
|             pass | ||||
| 
 | ||||
|     def search(self, game_name: str, case_sensitive: bool = True) -> list[GameEntry] | None: | ||||
|         if not game_name or not game_name.strip(): | ||||
|             return None | ||||
| 
 | ||||
|         json_response = self.http_client.search_games(game_name, search_modifiers) | ||||
|         # Проверяем кэш | ||||
|         cached_response = self._load_from_cache(game_name) | ||||
|         if cached_response: | ||||
|             try: | ||||
|                 cached_data = orjson.loads(cached_response) | ||||
|                 full_json = { | ||||
|                     "data": [ | ||||
|                         { | ||||
|                             "game_id": game["game_id"], | ||||
|                             "game_name": game["game_name"], | ||||
|                             "comp_main": game["comp_main"], | ||||
|                             "comp_plus": game["comp_plus"], | ||||
|                             "comp_100": game["comp_100"] | ||||
|                         } | ||||
|                         for game in cached_data.get("data", []) | ||||
|                     ] | ||||
|                 } | ||||
|                 parser = ResultParser( | ||||
|                     game_name, | ||||
|                     self.minimum_similarity, | ||||
|                     case_sensitive | ||||
|                 ) | ||||
|                 return parser.parse_results(orjson.dumps(full_json).decode('utf-8')) | ||||
|             except orjson.JSONDecodeError: | ||||
|                 pass | ||||
|         # Если нет в кэше, делаем запрос | ||||
|         json_response = self.http_client.search_games(game_name) | ||||
|         if not json_response: | ||||
|             return None | ||||
| 
 | ||||
|         # Сохраняем в кэш только первую игру | ||||
|         self._save_to_cache(game_name, json_response) | ||||
|         parser = ResultParser( | ||||
|             game_name, | ||||
|             self.minimum_similarity, | ||||
|             case_sensitive, | ||||
|             self.auto_filter_times | ||||
|             case_sensitive | ||||
|         ) | ||||
| 
 | ||||
|         return parser.parse_results(json_response) | ||||
| 
 | ||||
|     def search_by_id(self, game_id: int) -> GameEntry | None: | ||||
|         """Ищет игру по ID.""" | ||||
|         if not game_id or game_id <= 0: | ||||
|     def format_game_time(self, game_entry: GameEntry, time_field: str = "main_story") -> str | None: | ||||
|         time_value = getattr(game_entry, time_field, None) | ||||
|         if time_value is None: | ||||
|             return None | ||||
|         time_seconds = int(time_value * 3600) | ||||
|         return format_playtime(time_seconds) | ||||
| 
 | ||||
|         game_title = self.http_client.get_game_title(game_id) | ||||
|         if not game_title: | ||||
|             return None | ||||
|     def search_with_callback(self, game_name: str, case_sensitive: bool = True): | ||||
|         """Выполняет поиск игры в фоновом потоке и испускает сигнал с результатами.""" | ||||
|         def search_thread(): | ||||
|             try: | ||||
|                 results = self.search(game_name, case_sensitive) | ||||
|                 self.searchCompleted.emit(results if results else []) | ||||
|             except Exception as e: | ||||
|                 print(f"Error in search_with_callback: {e}") | ||||
|                 self.searchCompleted.emit([]) | ||||
| 
 | ||||
|         json_response = self.http_client.search_games(game_title) | ||||
|         if not json_response: | ||||
|             return None | ||||
| 
 | ||||
|         parser = ResultParser(game_title, 0.0, False, self.auto_filter_times) | ||||
|         results = parser.parse_results(json_response, target_game_id=game_id) | ||||
| 
 | ||||
|         return results[0] if results else None | ||||
|         thread = Thread(target=search_thread) | ||||
|         thread.daemon = True | ||||
|         thread.start() | ||||
| @@ -111,6 +111,8 @@ class InputManager(QObject): | ||||
|         self.stick_value = 0  # Текущее значение стика (для плавности) | ||||
|         self.dead_zone = 8000  # Мертвая зона стика | ||||
|  | ||||
|         self._is_gamescope_session = 'gamescope' in os.environ.get('DESKTOP_SESSION', '').lower() | ||||
|  | ||||
|         # Add variables for continuous D-pad movement | ||||
|         self.dpad_timer = QTimer(self) | ||||
|         self.dpad_timer.timeout.connect(self.handle_dpad_repeat) | ||||
| @@ -849,7 +851,7 @@ class InputManager(QObject): | ||||
|                     return True | ||||
|  | ||||
|             # Toggle fullscreen with F11 | ||||
|             if key == Qt.Key.Key_F11: | ||||
|             if key == Qt.Key.Key_F11 and not self._is_gamescope_session: | ||||
|                 self.toggle_fullscreen.emit(not self._is_fullscreen) | ||||
|                 return True | ||||
|  | ||||
| @@ -946,7 +948,7 @@ class InputManager(QObject): | ||||
|                     continue | ||||
|                 now = time.time() | ||||
|                 if event.type == ecodes.EV_KEY and event.value == 1: | ||||
|                     if event.code in BUTTONS['menu']: | ||||
|                     if event.code in BUTTONS['menu'] and not self._is_gamescope_session: | ||||
|                         self.toggle_fullscreen.emit(not self._is_fullscreen) | ||||
|                     else: | ||||
|                         self.button_pressed.emit(event.code) | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-07-06 17:56+0500\n" | ||||
| "POT-Creation-Date: 2025-07-14 13:16+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: de_DE\n" | ||||
| @@ -563,6 +563,15 @@ msgstr "" | ||||
| msgid "PLAY TIME" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "MAIN STORY" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "MAIN + SIDES" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "COMPLETIONIST" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "full" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-07-06 17:56+0500\n" | ||||
| "POT-Creation-Date: 2025-07-14 13:16+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: es_ES\n" | ||||
| @@ -563,6 +563,15 @@ msgstr "" | ||||
| msgid "PLAY TIME" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "MAIN STORY" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "MAIN + SIDES" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "COMPLETIONIST" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "full" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PortProtonQt 0.1.1\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-07-06 17:56+0500\n" | ||||
| "POT-Creation-Date: 2025-07-14 13:16+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language-Team: LANGUAGE <LL@li.org>\n" | ||||
| @@ -561,6 +561,15 @@ msgstr "" | ||||
| msgid "PLAY TIME" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "MAIN STORY" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "MAIN + SIDES" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "COMPLETIONIST" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "full" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							| @@ -9,8 +9,8 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-07-06 17:56+0500\n" | ||||
| "PO-Revision-Date: 2025-07-06 17:56+0500\n" | ||||
| "POT-Creation-Date: 2025-07-14 13:16+0500\n" | ||||
| "PO-Revision-Date: 2025-07-14 13:16+0500\n" | ||||
| "Last-Translator: \n" | ||||
| "Language: ru_RU\n" | ||||
| "Language-Team: ru_RU <LL@li.org>\n" | ||||
| @@ -572,6 +572,15 @@ msgstr "Последний запуск" | ||||
| msgid "PLAY TIME" | ||||
| msgstr "Время игры" | ||||
|  | ||||
| msgid "MAIN STORY" | ||||
| msgstr "СЮЖЕТ" | ||||
|  | ||||
| msgid "MAIN + SIDES" | ||||
| msgstr "СЮЖЕТ + ПОБОЧКИ" | ||||
|  | ||||
| msgid "COMPLETIONIST" | ||||
| msgstr "100%" | ||||
|  | ||||
| msgid "full" | ||||
| msgstr "полная" | ||||
|  | ||||
|   | ||||
| @@ -31,6 +31,7 @@ from portprotonqt.config_utils import ( | ||||
| ) | ||||
| from portprotonqt.localization import _, get_egs_language, read_metadata_translations | ||||
| from portprotonqt.logger import get_logger | ||||
| from portprotonqt.howlongtobeat_api import HowLongToBeat | ||||
| from portprotonqt.downloader import Downloader | ||||
|  | ||||
| from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider, | ||||
| @@ -1517,6 +1518,8 @@ class MainWindow(QMainWindow): | ||||
|         self._animations = {} | ||||
|         imageLabel = QLabel() | ||||
|         imageLabel.setFixedSize(300, 400) | ||||
|         self._detail_page_active = True | ||||
|         self._current_detail_page = detailPage | ||||
|  | ||||
|         if cover_path: | ||||
|             def on_pixmap_ready(pixmap): | ||||
| @@ -1589,7 +1592,7 @@ class MainWindow(QMainWindow): | ||||
|         badge_spacing = 5 | ||||
|         top_y = 10 | ||||
|         badge_y_positions = [] | ||||
|         badge_width = int(300 * 2/3)  # 2/3 ширины обложки (300 px) | ||||
|         badge_width = int(300 * 2/3) | ||||
|  | ||||
|         # ProtonDB бейдж | ||||
|         protondb_text = GameCard.getProtonDBText(protondb_tier) | ||||
| @@ -1678,11 +1681,6 @@ class MainWindow(QMainWindow): | ||||
|             anticheat_visible = False | ||||
|  | ||||
|         # Расположение бейджей | ||||
|         right_margin = 8 | ||||
|         badge_spacing = 5 | ||||
|         top_y = 10 | ||||
|         badge_y_positions = [] | ||||
|         badge_width = int(300 * 2/3) | ||||
|         if steam_visible: | ||||
|             steam_x = 300 - badge_width - right_margin | ||||
|             steamLabel.move(steam_x, top_y) | ||||
| @@ -1736,22 +1734,102 @@ class MainWindow(QMainWindow): | ||||
|         descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE) | ||||
|         detailsLayout.addWidget(descLabel) | ||||
|  | ||||
|         infoLayout = QHBoxLayout() | ||||
|         infoLayout.setSpacing(10) | ||||
|         # Инициализация HowLongToBeat | ||||
|         hltb = HowLongToBeat(parent=self) | ||||
|  | ||||
|         # Создаем общий layout для всей игровой информации | ||||
|         gameInfoLayout = QVBoxLayout() | ||||
|         gameInfoLayout.setSpacing(10) | ||||
|  | ||||
|         # Первая строка: Last Launch и Play Time | ||||
|         firstRowLayout = QHBoxLayout() | ||||
|         firstRowLayout.setSpacing(10) | ||||
|  | ||||
|         # Last Launch | ||||
|         lastLaunchTitle = QLabel(_("LAST LAUNCH")) | ||||
|         lastLaunchTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE) | ||||
|         lastLaunchValue = QLabel(last_launch) | ||||
|         lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE) | ||||
|         firstRowLayout.addWidget(lastLaunchTitle) | ||||
|         firstRowLayout.addWidget(lastLaunchValue) | ||||
|         firstRowLayout.addSpacing(30) | ||||
|  | ||||
|         # Play Time | ||||
|         playTimeTitle = QLabel(_("PLAY TIME")) | ||||
|         playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE) | ||||
|         playTimeValue = QLabel(formatted_playtime) | ||||
|         playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE) | ||||
|         infoLayout.addWidget(lastLaunchTitle) | ||||
|         infoLayout.addWidget(lastLaunchValue) | ||||
|         infoLayout.addSpacing(30) | ||||
|         infoLayout.addWidget(playTimeTitle) | ||||
|         infoLayout.addWidget(playTimeValue) | ||||
|         detailsLayout.addLayout(infoLayout) | ||||
|         firstRowLayout.addWidget(playTimeTitle) | ||||
|         firstRowLayout.addWidget(playTimeValue) | ||||
|  | ||||
|         gameInfoLayout.addLayout(firstRowLayout) | ||||
|  | ||||
|         # Создаем placeholder для второй строки (HLTB данные) | ||||
|         hltbLayout = QHBoxLayout() | ||||
|         hltbLayout.setSpacing(10) | ||||
|  | ||||
|         # Время прохождения (Main Story, Main + Sides, Completionist) | ||||
|         def on_hltb_results(results): | ||||
|             if not hasattr(self, '_detail_page_active') or not self._detail_page_active: | ||||
|                 return | ||||
|             if not self._current_detail_page or self._current_detail_page.isHidden() or not self._current_detail_page.parent(): | ||||
|                 return | ||||
|  | ||||
|             if results: | ||||
|                 game = results[0]  # Берем первый результат | ||||
|                 main_story_time = hltb.format_game_time(game, "main_story") | ||||
|                 main_extra_time = hltb.format_game_time(game, "main_extra") | ||||
|                 completionist_time = hltb.format_game_time(game, "completionist") | ||||
|  | ||||
|                 # Очищаем layout перед добавлением новых элементов | ||||
|                 while hltbLayout.count(): | ||||
|                     child = hltbLayout.takeAt(0) | ||||
|                     if child.widget(): | ||||
|                         child.widget().deleteLater() | ||||
|  | ||||
|                 has_data = False | ||||
|  | ||||
|                 if main_story_time is not None: | ||||
|                     mainStoryTitle = QLabel(_("MAIN STORY")) | ||||
|                     mainStoryTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE) | ||||
|                     mainStoryValue = QLabel(main_story_time) | ||||
|                     mainStoryValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE) | ||||
|                     hltbLayout.addWidget(mainStoryTitle) | ||||
|                     hltbLayout.addWidget(mainStoryValue) | ||||
|                     hltbLayout.addSpacing(30) | ||||
|                     has_data = True | ||||
|  | ||||
|                 if main_extra_time is not None: | ||||
|                     mainExtraTitle = QLabel(_("MAIN + SIDES")) | ||||
|                     mainExtraTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE) | ||||
|                     mainExtraValue = QLabel(main_extra_time) | ||||
|                     mainExtraValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE) | ||||
|                     hltbLayout.addWidget(mainExtraTitle) | ||||
|                     hltbLayout.addWidget(mainExtraValue) | ||||
|                     hltbLayout.addSpacing(30) | ||||
|                     has_data = True | ||||
|  | ||||
|                 if completionist_time is not None: | ||||
|                     completionistTitle = QLabel(_("COMPLETIONIST")) | ||||
|                     completionistTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE) | ||||
|                     completionistValue = QLabel(completionist_time) | ||||
|                     completionistValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE) | ||||
|                     hltbLayout.addWidget(completionistTitle) | ||||
|                     hltbLayout.addWidget(completionistValue) | ||||
|                     has_data = True | ||||
|  | ||||
|                 # Если есть данные, добавляем layout во вторую строку | ||||
|                 if has_data: | ||||
|                     gameInfoLayout.addLayout(hltbLayout) | ||||
|  | ||||
|         # Подключаем сигнал searchCompleted к on_hltb_results | ||||
|         hltb.searchCompleted.connect(on_hltb_results) | ||||
|  | ||||
|         # Запускаем поиск в фоновом потоке | ||||
|         hltb.search_with_callback(name, case_sensitive=False) | ||||
|  | ||||
|         # Добавляем общий layout с игровой информацией | ||||
|         detailsLayout.addLayout(gameInfoLayout) | ||||
|  | ||||
|         if controller_support: | ||||
|             cs = controller_support.lower() | ||||
| @@ -1769,7 +1847,7 @@ class MainWindow(QMainWindow): | ||||
|  | ||||
|         detailsLayout.addStretch(1) | ||||
|  | ||||
|         # Определяем текущий идентификатор игры по exec_line для корректного отображения кнопки | ||||
|         # Определяем текущий идентификатор игры по exec_line | ||||
|         entry_exec_split = shlex.split(exec_line) | ||||
|         if not entry_exec_split: | ||||
|             return | ||||
| @@ -1870,6 +1948,8 @@ class MainWindow(QMainWindow): | ||||
|     def goBackDetailPage(self, page: QWidget | None) -> None: | ||||
|         if page is None or page != self.stackedWidget.currentWidget(): | ||||
|             return | ||||
|         self._detail_page_active = False | ||||
|         self._current_detail_page = None | ||||
|         self.stackedWidget.setCurrentIndex(0) | ||||
|         self.stackedWidget.removeWidget(page) | ||||
|         page.deleteLater() | ||||
|   | ||||
		Reference in New Issue
	
	Block a user