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