28 Commits

Author SHA1 Message Date
83076d3dfc chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-20 12:34:06 +05:00
04aaf68e36 fix: Allow context menu for PortProton games without valid exe
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-20 12:31:36 +05:00
e91037708a fix(main_window): prevent RuntimeError when modifying deleted QVBoxLayout in HLTB callback
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-18 20:54:45 +05:00
1b743026c2 chore(build): clean appimage more agressive
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-18 15:26:51 +05:00
30b4cec4d1 chore(todo): fix typos
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-18 00:02:11 +05:00
db68c9050c chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-16 20:16:02 +05:00
1a93d5b82c chore(build): rework appimage dependency list
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-16 20:04:56 +05:00
cc0690cf9e fix: added perllib to appimage for fix exiftool work
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-16 19:58:56 +05:00
809ba2c976 chore(readme): mention all licences
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-16 19:42:22 +05:00
68c9636e10 chore(todo): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-16 16:56:18 +05:00
f0df1f89be chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 20:04:34 +05:00
f25224b668 refactor(cli): remove unused --session flag
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 20:00:43 +05:00
0cda47fdfd fix(input_manager): disable fullscreen toggle from keyboard/gamepad in gamescope session
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 19:58:05 +05:00
1a8c733580 chore(todo): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 13:23:44 +05:00
2476bea32a chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 13:19:36 +05:00
1bbc95a5c1 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 13:18:40 +05:00
d12b801191 feat: added data from How Long To Beat to GameCard
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 13:15:17 +05:00
233dab1269 feat: added module for work with howlongtobeat.com
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-13 08:52:15 +05:00
700a478598 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-12 11:49:43 +05:00
0fe727331f fix: portprotonqt-session-select path
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-12 11:48:39 +05:00
599644c4f6 fix(portproton-api): use normalize name from steam-api
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-11 13:49:26 +05:00
Gitea Actions
409e06f531 chore: update steam apps list 2025-07-11T08:34:58Z 2025-07-11 08:34:59 +00:00
4818cf5b67 fix(dev-scripts): parse all topics from linux-gaming
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-11 13:31:28 +05:00
59bfcdbbba feat(dev-scripts): add DEBUG_MODE to disable SSL verification
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-11 10:39:06 +05:00
989af36e5b feat(dev-scripts): add environment-based source toggling
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-11 10:29:17 +05:00
8300857aaa chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-10 23:09:34 +05:00
aea1a36cfd feat: open ppdb on portproton badge click
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-10 23:07:18 +05:00
f7a4fa6a17 chore(docs): move TODOs from README to TODO.md
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-10 22:01:49 +05:00
37 changed files with 5793 additions and 999 deletions

View File

@@ -9,13 +9,21 @@
- Переводы в переопределениях (за подробностями в документацию) - Переводы в переопределениях (за подробностями в документацию)
- Обложки и описания для всех автоинсталлов - Обложки и описания для всех автоинсталлов
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры - Возможность указать ссылку для скачивания обложки в диалоге добавления игры
- Интеграция с howlongtobeat.com
### Changed ### Changed
- Оптимизированны обложки автоинсталлов - Оптимизированны обложки автоинсталлов
- Папка custom_data исключена из сборки модуля для уменьшение его размера - Папка custom_data исключена из сборки модуля для уменьшение его размера
- Бейдж PortProton теперь открывает PortProtonDB
- Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в gamescope сессии
- Удалён аргумент `--session` так как тестирование gamescope сессии завершено
- В контекстном меню игр без exe файла теперь отображается только пункт "Удалить из PortProton"
### Fixed ### Fixed
- Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси - Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси
- Путь к portprotonqt-session-select в оверлее
- Работа exiftool в AppImage
- Открытие контекстного меню у игр без exe
### Contributors ### Contributors
- @Vector_null - @Vector_null

13
LICENSE
View File

@@ -73,6 +73,19 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
===============================
= HowLongToBeat-Python-API : =
===============================
MIT License
Copyright (c) 2020 JaeguKim
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============== ==============
= legendary: = = legendary: =
============== ==============

View File

@@ -4,79 +4,6 @@
<p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p> <p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
</div> </div>
## В планах
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
- [X] Добавить возможность управления с геймпада
- [ ] Добавить возможность управления с тачскрина
- [X] Добавить возможность управления с мыши и клавиатуры
- [X] Добавить систему тем [Документация](documentation/theme_guide)
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
- [X] Добавить метаданные для тем (скриншоты, описание, домашняя страница и автор)
- [ ] Продумать систему вкладок вместо текущей
- [ ] [Добавить сессию Gamescope, аналогичную той, что используется в SteamOS](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
- [X] Разобраться почему теряется часть стилей в Gamescope
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
- [X] Получать описания и названия игр из базы данных Steam
- [X] Получать обложки для игр из SteamGridDB или CDN Steam
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
- [ ] Убрать логи 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] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
- [X] Добавить индикацию запуска приложения
- [X] Достигнуть паритета функциональности с Ingame
- [ ] Достигнуть паритета функциональности с PortProton
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
- [X] Добавить переводы в переопределения
- [ ] Придумать как переопределять launcher.exe
- [X] Добавить в карточку игры сведения о поддержке геймпада
- [X] Добавить в карточки данные с ProtonDB
- [X] Добавить в карточки данные с AreWeAntiCheatYet
- [X] Продублировать бейджи с карточки на страницу с деталями игры
- [X] Добавить парсинг ярлыков из Steam
- [X] Добавить парсинг ярлыков из EGS
- [ ] Избавиться от бинарника legendary
- [X] Добавить запуск игр из EGS
- [ ] Добавить скачивание игр из EGS
- [ ] Добавить поддержку запуска сторонних игр из EGS
- [ ] Добавить авторизацию в EGS через WebView вместо ручного ввода
- [X] Получать описания для игр из EGS через их [API](https://store-content.ak.epicgames.com/api)
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam
- [X] Добавить поддержку версий Steam для Flatpak и Snap
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
- [X] Отображать описания игр и другие данные на языке системы
- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
- [X] Добавить систему избранного для карточек
- [X] Заменить все `print` на `logging`
- [ ] Привести все логи к единому языку
- [X] Уменьшить количество подстановок в переводах
- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
- [X] Исправить частичное применение тем на лету
- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
- [ ] Добавить поддержку GOG (?)
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
- [ ] Добавить данные с HowLongToBeat на страницу с деталями игры (?)
- [X] Добавить виброотдачу на геймпаде при запуске игры
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
- [ ] Доделать светлую тему
- [ ] Добавить подсказки к управлению с геймпада
- [ ] Добавить загрузку звуков в темы например для добавления звука запуска в тему и тд
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
### Установка (devel) ### Установка (devel)
```sh ```sh
@@ -124,11 +51,11 @@ pre-commit run --all-files
PortProtonQt использует код и зависимости от следующих проектов: PortProtonQt использует код и зависимости от следующих проектов:
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html). - [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://opensource.org/licenses/MIT). - [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
- [PortProton 2.0](https://git.linux-gaming.ru/CastroFidel/PortProton_2.0) — библиотека для взаимодействия с PortProton, лицензия [MIT](https://opensource.org/licenses/MIT). - [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] > [!WARNING]
> Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована > Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована

68
TODO.md Normal file
View File

@@ -0,0 +1,68 @@
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
- [X] Добавить возможность управления с геймпада
- [ ] Добавить возможность управления с тачскрина
- [X] Добавить возможность управления с мыши и клавиатуры
- [X] Добавить систему тем [Документация](documentation/theme_guide)
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
- [X] Добавить метаданные для тем (скриншоты, описание, домашняя страница и автор)
- [ ] Продумать систему вкладок вместо текущей
- [X] [Добавить сессию Gamescope, аналогичную той, что используется в SteamOS](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
- [X] Разобраться почему теряется часть стилей в Gamescope
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
- [X] Получать описания и названия игр из базы данных Steam
- [X] Получать обложки для игр из SteamGridDB или CDN Steam
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
- [X] Избавиться от вызовов yad
- [X] Реализовать собственный системный трей вместо использования трея PortProton
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
- [X] Добавить индикацию запуска приложения
- [X] Достигнуть паритета функциональности с Ingame
- [ ] Достигнуть паритета функциональности с PortProton
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
- [X] Добавить переводы в переопределения
- [X] Добавить в карточку игры сведения о поддержке геймпада
- [X] Добавить в карточки данные с ProtonDB
- [X] Добавить в карточки данные с AreWeAntiCheatYet
- [X] Продублировать бейджи с карточки на страницу с деталями игры
- [X] Добавить парсинг ярлыков из Steam
- [X] Добавить парсинг ярлыков из EGS
- [ ] Избавиться от бинарника legendary
- [X] Добавить запуск игр из EGS
- [ ] Добавить скачивание игр из EGS
- [ ] Добавить поддержку запуска сторонних игр из EGS
- [ ] Добавить авторизацию в EGS через WebView вместо ручного ввода
- [X] Получать описания для игр из EGS через их [API](https://store-content.ak.epicgames.com/api)
- [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)
- [X] Отображать описания игр и другие данные на языке системы
- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
- [X] Добавить систему избранного для карточек
- [X] Заменить все `print` на `logging`
- [ ] Привести все логи к единому языку
- [X] Уменьшить количество подстановок в переводах
- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
- [X] Исправить частичное применение тем на лету
- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
- [ ] Добавить поддержку GOG (?)
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
- [X] Добавить данные с HowLongToBeat на страницу с деталями игры
- [X] Добавить виброотдачу на геймпаде при запуске игры
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
- [ ] Доделать светлую тему
- [ ] Добавить подсказки к управлению с геймпада
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры

View File

@@ -1,5 +1,4 @@
version: 1 version: 1
script: script:
# 1) чистим старый AppDir # 1) чистим старый AppDir
- rm -rf AppDir || true - 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*} - 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 - 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*) - 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: AppDir:
path: ./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: app_info:
id: ru.linux_gaming.PortProtonQt id: ru.linux_gaming.PortProtonQt
name: PortProtonQt name: PortProtonQt
@@ -28,15 +48,13 @@ AppDir:
version: 0.1.3 version: 0.1.3
exec: usr/bin/python3 exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@" exec_args: "-m portprotonqt.app $@"
apt: apt:
arch: amd64 arch: amd64
sources: sources:
- sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse' - 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' key_url: 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x871920d1991bc93c'
include: include:
- python3 - python3-minimal
- python3-pkg-resources - python3-pkg-resources
- libopengl0 - libopengl0
- libk5crypto3 - libk5crypto3
@@ -45,13 +63,23 @@ AppDir:
- libxcb-cursor0 - libxcb-cursor0
- libimage-exiftool-perl - libimage-exiftool-perl
- xdg-utils - xdg-utils
exclude: [] exclude:
# Документация и man-страницы
- "*-doc"
- "*-man"
- manpages
- mandb
# Статические библиотеки
- "*-dev"
- "*-static"
# Дебаг-символы
- "*-dbg"
- "*-dbgsym"
runtime: runtime:
env: env:
PYTHONHOME: '${APPDIR}/usr' PYTHONHOME: '${APPDIR}/usr'
PYTHONPATH: '${APPDIR}/usr/local/lib/python3.10/dist-packages' 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: AppImage:
sign-key: None sign-key: None
arch: x86_64 arch: x86_64

View File

@@ -6,7 +6,7 @@ arch=('any')
url="https://git.linux-gaming.ru/Boria138/PortProtonQt" url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
license=('GPL-3.0') license=('GPL-3.0')
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils') 'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4')
makedepends=('python-'{'build','installer','setuptools','wheel'}) makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver") source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
sha256sums=('SKIP') sha256sums=('SKIP')

View File

@@ -6,7 +6,7 @@ arch=('any')
url="https://git.linux-gaming.ru/Boria138/PortProtonQt" url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
license=('GPL-3.0') license=('GPL-3.0')
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils') 'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4')
makedepends=('python-'{'build','installer','setuptools','wheel'}) makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git") source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
sha256sums=('SKIP') sha256sums=('SKIP')

View File

@@ -44,6 +44,7 @@ Requires: python3-pefile
Requires: python3-pillow Requires: python3-pillow
Requires: perl-Image-ExifTool Requires: perl-Image-ExifTool
Requires: xdg-utils Requires: xdg-utils
Requires: python3-beautifulsoup4
%description -n python3-%{pypi_name}-git %description -n python3-%{pypi_name}-git
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup. This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.

View File

@@ -41,6 +41,7 @@ Requires: python3-pefile
Requires: python3-pillow Requires: python3-pillow
Requires: perl-Image-ExifTool Requires: perl-Image-ExifTool
Requires: xdg-utils Requires: xdg-utils
Requires: python3-beautifulsoup4
%description -n python3-%{pypi_name} %description -n python3-%{pypi_name}
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup. This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.

View File

@@ -9,7 +9,7 @@ _portprotonqt() {
esac esac
if [[ "$cur" == -* ]]; then if [[ "$cur" == -* ]]; then
COMPREPLY=( $( compgen -W '--fullscreen --session' -- "$cur" ) ) COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) )
return 0 return 0
fi fi

View File

@@ -1405,7 +1405,7 @@
}, },
{ {
"normalized_name": "wuthering waves", "normalized_name": "wuthering waves",
"status": "Planned" "status": "Running"
}, },
{ {
"normalized_name": "dota underlords", "normalized_name": "dota underlords",

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,4 +1,136 @@
[ [
{
"normalized_title": "return alive",
"slug": "return-alive"
},
{
"normalized_title": "s.t.a.l.k.e.r. anomaly g.a.m.m.a",
"slug": "s-t-a-l-k-e-r-anomaly-g-a-m-m-a"
},
{
"normalized_title": "recore",
"slug": "recore-definitive-edition"
},
{
"normalized_title": "no man's sky",
"slug": "no-mans-sky"
},
{
"normalized_title": "alan wake 2",
"slug": "alan-wake-2"
},
{
"normalized_title": "architect life a house design simulator",
"slug": "architect-life-a-house-design-simulator"
},
{
"normalized_title": "clair obscur expedition 33",
"slug": "clair-obscur-expedition-33"
},
{
"normalized_title": "metro 2033 redux",
"slug": "metro-2033-redux"
},
{
"normalized_title": "nova drift",
"slug": "nova-drift"
},
{
"normalized_title": "deathloop",
"slug": "deathloop"
},
{
"normalized_title": "mullet madjack",
"slug": "mullet-madjack"
},
{
"normalized_title": "luma island",
"slug": "luma-island"
},
{
"normalized_title": "cash cleaner simulator",
"slug": "cash-cleaner-simulator"
},
{
"normalized_title": "the plucky squire (отважный паж)",
"slug": "the-plucky-squire-otvazhnyj-pazh"
},
{
"normalized_title": "crsed cuisine royale",
"slug": "crsed-cuisine-royale"
},
{
"normalized_title": "tainted grail the fall of avalon",
"slug": "tainted-grail-the-fall-of-avalon"
},
{
"normalized_title": "battle of space raiders",
"slug": "battle-of-space-raiders"
},
{
"normalized_title": "gzdoom",
"slug": "gzdoom"
},
{
"normalized_title": "rain on your parade",
"slug": "rain-on-your-parade"
},
{
"normalized_title": "партизан (partisan widerstand hinter feindlichen linien)",
"slug": "partizan-partisan-widerstand-hinter-feindlichen-linien"
},
{
"normalized_title": "ebola 2",
"slug": "ebola-2"
},
{
"normalized_title": "monster care simulator",
"slug": "monster-care-simulator"
},
{
"normalized_title": "steins;gate the distant valhalla",
"slug": "steins-gate-the-distant-valhalla"
},
{
"normalized_title": "hogwarts legacy",
"slug": "hogwarts-legacy"
},
{
"normalized_title": "osu!",
"slug": "osu"
},
{
"normalized_title": "stalker online (stay out)",
"slug": "stalker-online-stay-out"
},
{
"normalized_title": "slitterhead",
"slug": "slitterhead"
},
{
"normalized_title": "indiana jones and the great circle",
"slug": "indiana-jones-and-the-great-circle"
},
{
"normalized_title": "crossout",
"slug": "crossout"
},
{
"normalized_title": "days gone",
"slug": "days-gone"
},
{
"normalized_title": "warcraft iii reforged 2.0",
"slug": "warcraft-iii-reforged-2-0"
},
{
"normalized_title": "biomutant",
"slug": "biomutant"
},
{
"normalized_title": "overwatch 2",
"slug": "overwatch-2"
},
{ {
"normalized_title": "settlement survival", "normalized_title": "settlement survival",
"slug": "settlement-survival" "slug": "settlement-survival"
@@ -551,10 +683,6 @@
"normalized_title": "snowrunner (ранее mudrunner 2)", "normalized_title": "snowrunner (ранее mudrunner 2)",
"slug": "snowrunner-ranee-mudrunner-2" "slug": "snowrunner-ranee-mudrunner-2"
}, },
{
"normalized_title": "alan wake 2",
"slug": "alan-wake-2"
},
{ {
"normalized_title": "verse project", "normalized_title": "verse project",
"slug": "verse-project" "slug": "verse-project"

Binary file not shown.

View File

@@ -5,12 +5,19 @@ import json
import asyncio import asyncio
import aiohttp import aiohttp
import tarfile import tarfile
import ssl
# Получаем ключи и данные из переменных окружения # Получаем ключи и данные из переменных окружения
STEAM_KEY = os.environ.get('STEAM_KEY') STEAM_KEY = os.environ.get('STEAM_KEY')
LINUX_GAMING_API_KEY = os.environ.get('LINUX_GAMING_API_KEY') LINUX_GAMING_API_KEY = os.environ.get('LINUX_GAMING_API_KEY')
LINUX_GAMING_API_USERNAME = os.environ.get('LINUX_GAMING_API_USERNAME') LINUX_GAMING_API_USERNAME = os.environ.get('LINUX_GAMING_API_USERNAME')
# Флаги для включения/отключения источников
ENABLE_STEAM = os.environ.get('ENABLE_STEAM', 'true').lower() == 'true'
ENABLE_ANTICHEAT = os.environ.get('ENABLE_ANTICHEAT', 'true').lower() == 'true'
ENABLE_LINUX_GAMING = os.environ.get('ENABLE_LINUX_GAMING', 'true').lower() == 'true'
DEBUG_MODE = os.environ.get('DEBUG_MODE', 'false').lower() == 'true'
# Конфигурация API # Конфигурация API
STEAM_BASE_URL = "https://api.steampowered.com/IStoreService/GetAppList/v1/?" STEAM_BASE_URL = "https://api.steampowered.com/IStoreService/GetAppList/v1/?"
LINUX_GAMING_BASE_URL = "https://linux-gaming.ru" LINUX_GAMING_BASE_URL = "https://linux-gaming.ru"
@@ -21,6 +28,10 @@ LINUX_GAMING_HEADERS = {
"Api-Username": LINUX_GAMING_API_USERNAME "Api-Username": LINUX_GAMING_API_USERNAME
} }
# Отключаем предупреждения об SSL в дебаг-режиме
if DEBUG_MODE:
print("DEBUG_MODE enabled: SSL verification is disabled (insecure, use for debugging only).")
def normalize_name(s): def normalize_name(s):
""" """
Приведение строки к нормальному виду: Приведение строки к нормальному виду:
@@ -69,7 +80,7 @@ async def get_app_list(session, last_appid, endpoint):
url = endpoint url = endpoint
if last_appid: if last_appid:
url = f"{url}&last_appid={last_appid}" url = f"{url}&last_appid={last_appid}"
async with session.get(url) as response: async with session.get(url, verify_ssl=not DEBUG_MODE) as response:
response.raise_for_status() response.raise_for_status()
return await response.json() return await response.json()
@@ -79,7 +90,7 @@ async def fetch_games_json(session):
""" """
url = "https://raw.githubusercontent.com/AreWeAntiCheatYet/AreWeAntiCheatYet/HEAD/games.json" url = "https://raw.githubusercontent.com/AreWeAntiCheatYet/AreWeAntiCheatYet/HEAD/games.json"
try: try:
async with session.get(url) as response: async with session.get(url, verify_ssl=not DEBUG_MODE) as response:
response.raise_for_status() response.raise_for_status()
text = await response.text() text = await response.text()
data = json.loads(text) data = json.loads(text)
@@ -89,39 +100,139 @@ async def fetch_games_json(session):
return [] return []
async def get_linux_gaming_topics(session, category_slug): async def get_linux_gaming_topics(session, category_slug):
"""
Получает все темы из указанной категории linux-gaming.ru.
Сохраняет только нормализованное название (normalized_title) и slug.
"""
page = 0 page = 0
all_topics = [] all_topics = []
max_pages = 100
while True: while page < max_pages:
page += 1 # Пробуем несколько вариантов URL
url = f"{LINUX_GAMING_BASE_URL}/c/{category_slug}/l/latest.json?page={page}" urls_to_try = [
f"{LINUX_GAMING_BASE_URL}/c/{category_slug}/5/l/latest.json", # с id категории
f"{LINUX_GAMING_BASE_URL}/c/{category_slug}/l/latest.json", # только slug
f"{LINUX_GAMING_BASE_URL}/c/5/l/latest.json", # только id
f"{LINUX_GAMING_BASE_URL}/latest.json" # все темы
]
success = False
data = None
for url in urls_to_try:
try: try:
async with session.get(url, headers=LINUX_GAMING_HEADERS) as response: # Добавляем параметры пагинации
params = {
'page': page,
'order': 'default'
}
async with session.get(url, headers=LINUX_GAMING_HEADERS,
params=params, verify_ssl=not DEBUG_MODE) as response:
if response.status == 429:
print(f"Слишком много запросов на странице {page}, ожидание...")
await asyncio.sleep(5)
continue
if response.status == 404:
if DEBUG_MODE:
print(f"URL не найден: {url}")
continue
response.raise_for_status() response.raise_for_status()
data = await response.json() data = await response.json()
topics = data.get("topic_list", {}).get("topics", [])
# Проверяем структуру ответа
topic_list = data.get("topic_list", {})
topics = topic_list.get("topics", [])
if not topics: if not topics:
break if page == 0:
for topic in topics: if DEBUG_MODE:
all_topics.append({ print(f"Нет тем в URL: {url}")
"normalized_title": normalize_name(topic["title"]), continue
"slug": topic["slug"] else:
}) print(f"Страница {page} пуста, завершаем пагинацию.")
print(f"Обработано {len(topics)} тем на странице {page}, всего: {len(all_topics)}.")
except Exception as error:
print(f"Ошибка получения тем для страницы {page}: {error}")
break
return all_topics return all_topics
if DEBUG_MODE and page == 0:
print(f"Успешно подключились к URL: {url}")
success = True
break
except Exception as e:
if DEBUG_MODE:
print(f"Ошибка с URL {url}: {e}")
continue
if not success:
print(f"Не удалось загрузить страницу {page}")
break
# Обрабатываем темы (этот блок должен быть внутри основного цикла)
try:
topic_list = data.get("topic_list", {})
topics = topic_list.get("topics", [])
page_topics_added = 0
for topic in topics:
slug = topic["slug"]
# Пропускаем тему описания категории
if slug is None or slug == "opisanie-kategorii-portprotondb":
if DEBUG_MODE:
print(f"Пропущена тема описания категории")
continue
normalized_title = normalize_name(topic["title"])
# Добавляем только валидные темы
all_topics.append({
"normalized_title": normalized_title,
"slug": slug,
})
page_topics_added += 1
if DEBUG_MODE and page_topics_added <= 3: # Показываем первые 3 темы
print(f"Добавлена тема: {normalized_title} (slug: {slug}")
print(f"Обработано {len(topics)} тем на странице {page}, добавлено: {page_topics_added}, всего: {len(all_topics)}.")
# Проверяем, есть ли еще страницы
more_topics_url = topic_list.get("more_topics_url")
if not more_topics_url:
print("Больше тем нет, завершаем пагинацию.")
break
page += 1
# Добавляем небольшую задержку между запросами
await asyncio.sleep(0.5)
except Exception as e:
print(f"Ошибка при обработке тем на странице {page}: {e}")
break
if not all_topics:
print("Предупреждение: не удалось получить ни одной темы из linux-gaming.ru.")
else:
print(f"Всего получено {len(all_topics)} тем из категории {category_slug}")
return all_topics
async def request_data(): async def request_data():
""" """
Получает данные из Steam, AreWeAntiCheatYet и linux-gaming.ru, Получает данные из Steam, AreWeAntiCheatYet и linux-gaming.ru,
обрабатывает их и сохраняет в JSON-файлы и tar.xz архивы. обрабатывает их и сохраняет в JSON-файлы и tar.xz архивы.
""" """
output_json = []
total_parsed = 0
linux_gaming_topics = []
anticheat_games = []
try:
async with aiohttp.ClientSession() as session:
# Загружаем данные Steam
if ENABLE_STEAM:
# Параметры запроса для Steam # Параметры запроса для Steam
game_param = "&include_games=true" game_param = "&include_games=true"
dlc_param = "&include_dlc=false" dlc_param = "&include_dlc=false"
@@ -135,14 +246,6 @@ async def request_data():
f"&max_results=50000" f"&max_results=50000"
) )
output_json = []
total_parsed = 0
linux_gaming_topics = []
anticheat_games = []
try:
async with aiohttp.ClientSession() as session:
# Загружаем данные Steam
have_more_results = True have_more_results = True
last_appid_val = None last_appid_val = None
while have_more_results: while have_more_results:
@@ -154,15 +257,23 @@ async def request_data():
have_more_results = app_list['response'].get('have_more_results', False) have_more_results = app_list['response'].get('have_more_results', False)
last_appid_val = app_list['response'].get('last_appid') last_appid_val = app_list['response'].get('last_appid')
print(f"Обработано {len(apps)} игр Steam, всего: {total_parsed}.") print(f"Обработано {len(apps)} игр Steam, всего: {total_parsed}.")
else:
print("Пропущена загрузка данных Steam (ENABLE_STEAM=false).")
# Загружаем данные AreWeAntiCheatYet # Загружаем данные AreWeAntiCheatYet
if ENABLE_ANTICHEAT:
anticheat_games = await fetch_games_json(session) anticheat_games = await fetch_games_json(session)
else:
print("Пропущена загрузка данных AreWeAntiCheatYet (ENABLE_ANTICHEAT=false).")
# Загружаем данные linux-gaming.ru # Загружаем данные linux-gaming.ru
if ENABLE_LINUX_GAMING:
if LINUX_GAMING_API_KEY and LINUX_GAMING_API_USERNAME: if LINUX_GAMING_API_KEY and LINUX_GAMING_API_USERNAME:
linux_gaming_topics = await get_linux_gaming_topics(session, CATEGORY_LINUX_GAMING) linux_gaming_topics = await get_linux_gaming_topics(session, CATEGORY_LINUX_GAMING)
else: else:
print("Предупреждение: LINUX_GAMING_API_KEY или LINUX_GAMING_API_USERNAME не установлены.") print("Предупреждение: LINUX_GAMING_API_KEY или LINUX_GAMING_API_USERNAME не установлены.")
else:
print("Пропущена загрузка данных linux-gaming.ru (ENABLE_LINUX_GAMING=false).")
except Exception as error: except Exception as error:
print(f"Ошибка получения данных: {error}") print(f"Ошибка получения данных: {error}")
@@ -173,6 +284,7 @@ async def request_data():
os.makedirs(data_dir, exist_ok=True) os.makedirs(data_dir, exist_ok=True)
# Сохранение данных Steam # Сохранение данных Steam
if ENABLE_STEAM and output_json:
output_json_full = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid.json") output_json_full = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid.json")
output_json_min = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid_min.json") output_json_min = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid_min.json")
with open(output_json_full, "w", encoding="utf-8") as f: with open(output_json_full, "w", encoding="utf-8") as f:
@@ -180,25 +292,7 @@ async def request_data():
with open(output_json_min, "w", encoding="utf-8") as f: with open(output_json_min, "w", encoding="utf-8") as f:
json.dump(output_json, f, ensure_ascii=False, separators=(',',':')) json.dump(output_json, f, ensure_ascii=False, separators=(',',':'))
# Сохранение данных AreWeAntiCheatYet # Упаковка минифицированного JSON Steam в tar.xz архив
anticheat_json_full = os.path.join(data_dir, "anticheat_games.json")
anticheat_json_min = os.path.join(data_dir, "anticheat_games_min.json")
with open(anticheat_json_full, "w", encoding="utf-8") as f:
json.dump(anticheat_games, f, ensure_ascii=False, indent=2)
with open(anticheat_json_min, "w", encoding="utf-8") as f:
json.dump(anticheat_games, f, ensure_ascii=False, separators=(',',':'))
# Сохранение данных linux-gaming.ru
linux_gaming_json_full = os.path.join(data_dir, "linux_gaming_topics.json")
linux_gaming_json_min = os.path.join(data_dir, "linux_gaming_topics_min.json")
if linux_gaming_topics:
with open(linux_gaming_json_full, "w", encoding="utf-8") as f:
json.dump(linux_gaming_topics, f, ensure_ascii=False, indent=2)
with open(linux_gaming_json_min, "w", encoding="utf-8") as f:
json.dump(linux_gaming_topics, f, ensure_ascii=False, separators=(',',':'))
# Упаковка минифицированных JSON в tar.xz архивы
# Архив для Steam
steam_archive_path = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid.tar.xz") steam_archive_path = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid.tar.xz")
try: try:
with tarfile.open(steam_archive_path, "w:xz", preset=9) as tar: with tarfile.open(steam_archive_path, "w:xz", preset=9) as tar:
@@ -209,7 +303,16 @@ async def request_data():
print(f"Ошибка при упаковке архива Steam: {e}") print(f"Ошибка при упаковке архива Steam: {e}")
return False return False
# Архив для AreWeAntiCheatYet # Сохранение данных AreWeAntiCheatYet
if ENABLE_ANTICHEAT and anticheat_games:
anticheat_json_full = os.path.join(data_dir, "anticheat_games.json")
anticheat_json_min = os.path.join(data_dir, "anticheat_games_min.json")
with open(anticheat_json_full, "w", encoding="utf-8") as f:
json.dump(anticheat_games, f, ensure_ascii=False, indent=2)
with open(anticheat_json_min, "w", encoding="utf-8") as f:
json.dump(anticheat_games, f, ensure_ascii=False, separators=(',',':'))
# Упаковка минифицированного JSON AreWeAntiCheatYet в tar.xz архив
anticheat_archive_path = os.path.join(data_dir, "anticheat_games.tar.xz") anticheat_archive_path = os.path.join(data_dir, "anticheat_games.tar.xz")
try: try:
with tarfile.open(anticheat_archive_path, "w:xz", preset=9) as tar: with tarfile.open(anticheat_archive_path, "w:xz", preset=9) as tar:
@@ -220,8 +323,16 @@ async def request_data():
print(f"Ошибка при упаковке архива AreWeAntiCheatYet: {e}") print(f"Ошибка при упаковке архива AreWeAntiCheatYet: {e}")
return False return False
# Архив для linux-gaming.ru # Сохранение данных linux-gaming.ru
if linux_gaming_topics: if ENABLE_LINUX_GAMING and linux_gaming_topics:
linux_gaming_json_full = os.path.join(data_dir, "linux_gaming_topics.json")
linux_gaming_json_min = os.path.join(data_dir, "linux_gaming_topics_min.json")
with open(linux_gaming_json_full, "w", encoding="utf-8") as f:
json.dump(linux_gaming_topics, f, ensure_ascii=False, indent=2)
with open(linux_gaming_json_min, "w", encoding="utf-8") as f:
json.dump(linux_gaming_topics, f, ensure_ascii=False, separators=(',',':'))
# Упаковка минифицированного JSON linux-gaming.ru в tar.xz архив
linux_gaming_archive_path = os.path.join(data_dir, "linux_gaming_topics.tar.xz") linux_gaming_archive_path = os.path.join(data_dir, "linux_gaming_topics.tar.xz")
try: try:
with tarfile.open(linux_gaming_archive_path, "w:xz", preset=9) as tar: with tarfile.open(linux_gaming_archive_path, "w:xz", preset=9) as tar:

View File

@@ -20,9 +20,9 @@ Current translation status:
| Locale | Progress | Translated | | Locale | Progress | Translated |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 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 194 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 197 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 of 194 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 of 197 |
--- ---

View File

@@ -20,9 +20,9 @@
| Локаль | Прогресс | Переведено | | Локаль | Прогресс | Переведено |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 194 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 197 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 194 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 197 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 из 194 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 из 197 |
--- ---

View File

@@ -1,6 +1,4 @@
import sys import sys
import os
import subprocess
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon from PySide6.QtGui import QIcon
@@ -35,13 +33,6 @@ def main():
window = MainWindow() 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: if args.fullscreen:
logger.info("Launching in fullscreen mode due to --fullscreen flag") logger.info("Launching in fullscreen mode due to --fullscreen flag")
save_fullscreen_config(True) save_fullscreen_config(True)

View File

@@ -13,9 +13,4 @@ def parse_args():
action="store_true", action="store_true",
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку" help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
) )
parser.add_argument(
"--session",
action="store_true",
help="Запустить приложение с использованием gamescope"
)
return parser.parse_args() return parser.parse_args()

View File

@@ -148,10 +148,7 @@ class ContextMenuManager:
return False return False
current_exe = os.path.basename(exe_path) current_exe = os.path.basename(exe_path)
# Check if the current_exe matches the target_exe in MainWindow return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe
if hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe:
return True
return False
def show_context_menu(self, game_card, pos: QPoint): def show_context_menu(self, game_card, pos: QPoint):
""" """
@@ -161,7 +158,6 @@ class ContextMenuManager:
game_card: The GameCard instance requesting the context menu. game_card: The GameCard instance requesting the context menu.
pos: The position (in widget coordinates) where the menu should appear. pos: The position (in widget coordinates) where the menu should appear.
""" """
def get_safe_icon(icon_name: str) -> QIcon: def get_safe_icon(icon_name: str) -> QIcon:
icon = self.theme_manager.get_icon(icon_name) icon = self.theme_manager.get_icon(icon_name)
if isinstance(icon, QIcon): if isinstance(icon, QIcon):
@@ -173,7 +169,18 @@ class ContextMenuManager:
menu = QMenu(self.parent) menu = QMenu(self.parent)
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE) 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) is_running = self._is_game_running(game_card)
action_text = _("Stop Game") if is_running else _("Launch Game") action_text = _("Stop Game") if is_running else _("Launch Game")
action_icon = "stop" if is_running else "play" action_icon = "stop" if is_running else "play"
@@ -697,15 +704,12 @@ Icon={icon_path}
return None return None
return exec_line 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.""" """Parse the executable path from exec_line."""
try: try:
entry_exec_split = shlex.split(exec_line) entry_exec_split = shlex.split(exec_line)
if not entry_exec_split: if not entry_exec_split:
self.signals.show_warning_dialog.emit( logger.debug("Invalid executable command for '%s': %s", game_name, exec_line)
_("Error"),
_("Invalid executable command: {exec_line}").format(exec_line=exec_line)
)
return None return None
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3: if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
exe_path = entry_exec_split[2] exe_path = entry_exec_split[2]
@@ -714,17 +718,11 @@ Icon={icon_path}
else: else:
exe_path = entry_exec_split[-1] exe_path = entry_exec_split[-1]
if not exe_path or not os.path.exists(exe_path): if not exe_path or not os.path.exists(exe_path):
self.signals.show_warning_dialog.emit( logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None")
_("Error"),
_("Executable not found: {path}").format(path=exe_path or "None")
)
return None return None
return exe_path return exe_path
except Exception as e: except Exception as e:
self.signals.show_warning_dialog.emit( logger.debug("Failed to parse executable for '%s': %s", game_name, e)
_("Error"),
_("Failed to parse executable: {error}").format(error=str(e))
)
return None return None
def _remove_file(self, file_path, error_message, success_message, game_name, location=""): def _remove_file(self, file_path, error_message, success_message, game_name, location=""):

View File

@@ -9,6 +9,8 @@ from portprotonqt.config_utils import read_favorites, save_favorites, read_displ
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.custom_widgets import ClickableLabel from portprotonqt.custom_widgets import ClickableLabel
from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.downloader import Downloader
import weakref import weakref
from typing import cast from typing import cast
@@ -56,6 +58,8 @@ class GameCard(QFrame):
self.display_filter = read_display_filter() self.display_filter = read_display_filter()
self.current_theme_name = read_theme_from_config() self.current_theme_name = read_theme_from_config()
self.downloader = Downloader(max_workers=4)
self.portproton_api = PortProtonAPI(self.downloader)
self.steam_visible = (str(game_source).lower() == "steam" and self.display_filter in ("all", "favorites")) self.steam_visible = (str(game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites")) self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
@@ -194,13 +198,13 @@ class GameCard(QFrame):
parent=coverWidget, parent=coverWidget,
icon_size=icon_size, icon_size=icon_size,
icon_space=icon_space, icon_space=icon_space,
font_scale_factor=font_scale_factor, font_scale_factor=font_scale_factor
change_cursor=False
) )
self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.portprotonLabel.setFixedWidth(badge_width) self.portprotonLabel.setFixedWidth(badge_width)
self.portprotonLabel.setCardWidth(card_width) self.portprotonLabel.setCardWidth(card_width)
self.portprotonLabel.setVisible(self.portproton_visible) self.portprotonLabel.setVisible(self.portproton_visible)
self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic)
# WeAntiCheatYet бейдж # WeAntiCheatYet бейдж
anticheat_text = self.getAntiCheatText(anticheat_status) anticheat_text = self.getAntiCheatText(anticheat_status)
@@ -385,6 +389,16 @@ class GameCard(QFrame):
return "broken" return "broken"
return "" return ""
def open_portproton_forum_topic(self):
"""Open the PortProton forum topic or search page for this game."""
result = self.portproton_api.get_forum_topic_slug(self.name)
base_url = "https://linux-gaming.ru/"
if result.startswith("search?q="):
url = QUrl(f"{base_url}{result}")
else:
url = QUrl(f"{base_url}t/{result}")
QDesktopServices.openUrl(url)
def open_protondb_report(self): def open_protondb_report(self):
url = QUrl(f"https://www.protondb.com/app/{self.appid}") url = QUrl(f"https://www.protondb.com/app/{self.appid}")
QDesktopServices.openUrl(url) QDesktopServices.openUrl(url)

View File

@@ -0,0 +1,371 @@
import orjson
import re
import os
from dataclasses import dataclass, field
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
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
main_story: float | None = None
main_extra: float | None = None
completionist: float | None = None
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)
return config
@staticmethod
def _extract_api_key(script_content: str) -> str | None:
user_id_pattern = r'users\s*:\s*{\s*id\s*:\s*"([^"]+)"'
matches = re.findall(user_id_pattern, script_content)
if matches:
return ''.join(matches)
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:
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/'
SEARCH_URL = BASE_URL + "api/s/"
def __init__(self, timeout: int = 60):
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({
'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
})
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)
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)
for script_url in script_urls:
full_url = self.BASE_URL + script_url
script_response = self.session.get(full_url, timeout=self.timeout)
if script_response.status_code == 200:
config = APIKeyExtractor.extract_from_script(script_response.text)
if config.api_key:
return config
except requests.RequestException:
pass
return 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, page, config)
headers = {
'content-type': 'application/json',
'accept': '*/*'
}
try:
response = self.session.post(
search_url + config.api_key,
headers=headers,
data=orjson.dumps(payload),
timeout=self.timeout
)
if response.status_code == 200:
return response.text
except requests.RequestException:
pass
try:
response = self.session.post(
search_url,
headers=headers,
data=orjson.dumps(payload),
timeout=self.timeout
)
if response.status_code == 200:
return response.text
except requests.RequestException:
pass
return None
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': 1, # Limit to 1 result
'searchOptions': {
'games': {
'userId': 0,
'platform': "",
'sortCategory': "popular",
'rangeCategory': "main",
'rangeTime': {'min': 0, 'max': 0},
'gameplay': {
'perspective': "",
'flow': "",
'genre': "",
"difficulty": ""
},
'rangeYear': {'max': "", 'min': ""},
'modifier': "" # Hardcoded to empty string for SearchModifiers.NONE
},
'users': {'sortCategory': "postcount"},
'lists': {'sortCategory': "follows"},
'filter': "",
'sort': 0,
'randomizer': 0
},
'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:
"""Парсер результатов поиска."""
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.search_numbers = self._extract_numbers(search_query)
def parse_results(self, json_response: str, target_game_id: int | None = None) -> list[GameEntry]:
try:
data = orjson.loads(json_response)
games = []
# 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, 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.raw_data = game_data
time_fields = [
("comp_main", "main_story"),
("comp_plus", "main_extra"),
("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.similarity = self._calculate_similarity(game)
return game
def _calculate_similarity(self, game: GameEntry) -> float:
return self._compare_strings(self.search_query, game.game_name)
def _compare_strings(self, a: str | None, b: str | None) -> float:
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(QObject):
"""Основной класс для работы с API HowLongToBeat."""
searchCompleted = Signal(list)
def __init__(self, minimum_similarity: float = 0.4, timeout: int = 60, parent=None):
super().__init__(parent)
self.minimum_similarity = minimum_similarity
self.http_client = HTTPClient(timeout)
self.cache_dir = get_cache_dir()
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
# Проверяем кэш
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
)
return parser.parse_results(json_response)
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)
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([])
thread = Thread(target=search_thread)
thread.daemon = True
thread.start()

View File

@@ -111,6 +111,8 @@ class InputManager(QObject):
self.stick_value = 0 # Текущее значение стика (для плавности) self.stick_value = 0 # Текущее значение стика (для плавности)
self.dead_zone = 8000 # Мертвая зона стика self.dead_zone = 8000 # Мертвая зона стика
self._is_gamescope_session = 'gamescope' in os.environ.get('DESKTOP_SESSION', '').lower()
# Add variables for continuous D-pad movement # Add variables for continuous D-pad movement
self.dpad_timer = QTimer(self) self.dpad_timer = QTimer(self)
self.dpad_timer.timeout.connect(self.handle_dpad_repeat) self.dpad_timer.timeout.connect(self.handle_dpad_repeat)
@@ -849,7 +851,7 @@ class InputManager(QObject):
return True return True
# Toggle fullscreen with F11 # 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) self.toggle_fullscreen.emit(not self._is_fullscreen)
return True return True
@@ -946,7 +948,7 @@ class InputManager(QObject):
continue continue
now = time.time() now = time.time()
if event.type == ecodes.EV_KEY and event.value == 1: 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) self.toggle_fullscreen.emit(not self._is_fullscreen)
else: else:
self.button_pressed.emit(event.code) self.button_pressed.emit(event.code)

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n" "Language: de_DE\n"
@@ -563,6 +563,15 @@ msgstr ""
msgid "PLAY TIME" msgid "PLAY TIME"
msgstr "" msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full" msgid "full"
msgstr "" msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n" "Language: es_ES\n"
@@ -563,6 +563,15 @@ msgstr ""
msgid "PLAY TIME" msgid "PLAY TIME"
msgstr "" msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full" msgid "full"
msgstr "" msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n" "Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -561,6 +561,15 @@ msgstr ""
msgid "PLAY TIME" msgid "PLAY TIME"
msgstr "" msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full" msgid "full"
msgstr "" msgstr ""

View File

@@ -9,8 +9,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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: 2025-07-06 17:56+0500\n" "PO-Revision-Date: 2025-07-14 13:16+0500\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language: ru_RU\n" "Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n" "Language-Team: ru_RU <LL@li.org>\n"
@@ -572,6 +572,15 @@ msgstr "Последний запуск"
msgid "PLAY TIME" msgid "PLAY TIME"
msgstr "Время игры" msgstr "Время игры"
msgid "MAIN STORY"
msgstr "СЮЖЕТ"
msgid "MAIN + SIDES"
msgstr "СЮЖЕТ + ПОБОЧКИ"
msgid "COMPLETIONIST"
msgstr "100%"
msgid "full" msgid "full"
msgstr "полная" msgstr "полная"

View File

@@ -31,6 +31,7 @@ from portprotonqt.config_utils import (
) )
from portprotonqt.localization import _, get_egs_language, read_metadata_translations from portprotonqt.localization import _, get_egs_language, read_metadata_translations
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider, from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
@@ -248,6 +249,16 @@ class MainWindow(QMainWindow):
self.updateGameGrid() self.updateGameGrid()
self.progress_bar.setVisible(False) self.progress_bar.setVisible(False)
def open_portproton_forum_topic(self, topic_name: str):
"""Open the PortProton forum topic or search page for this game."""
result = self.portproton_api.get_forum_topic_slug(topic_name)
base_url = "https://linux-gaming.ru/"
if result.startswith("search?q="):
url = QUrl(f"{base_url}{result}")
else:
url = QUrl(f"{base_url}t/{result}")
QDesktopServices.openUrl(url)
def _on_card_focused(self, game_name: str, is_focused: bool): def _on_card_focused(self, game_name: str, is_focused: bool):
"""Обработчик сигнала focusChanged от GameCard.""" """Обработчик сигнала focusChanged от GameCard."""
card_key = None card_key = None
@@ -1507,6 +1518,8 @@ class MainWindow(QMainWindow):
self._animations = {} self._animations = {}
imageLabel = QLabel() imageLabel = QLabel()
imageLabel.setFixedSize(300, 400) imageLabel.setFixedSize(300, 400)
self._detail_page_active = True
self._current_detail_page = detailPage
if cover_path: if cover_path:
def on_pixmap_ready(pixmap): def on_pixmap_ready(pixmap):
@@ -1579,7 +1592,7 @@ class MainWindow(QMainWindow):
badge_spacing = 5 badge_spacing = 5
top_y = 10 top_y = 10
badge_y_positions = [] badge_y_positions = []
badge_width = int(300 * 2/3) # 2/3 ширины обложки (300 px) badge_width = int(300 * 2/3)
# ProtonDB бейдж # ProtonDB бейдж
protondb_text = GameCard.getProtonDBText(protondb_tier) protondb_text = GameCard.getProtonDBText(protondb_tier)
@@ -1639,11 +1652,11 @@ class MainWindow(QMainWindow):
parent=coverFrame, parent=coverFrame,
icon_size=16, icon_size=16,
icon_space=5, icon_space=5,
change_cursor=False
) )
portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
portprotonLabel.setFixedWidth(badge_width) portprotonLabel.setFixedWidth(badge_width)
portprotonLabel.setVisible(portproton_visible) portprotonLabel.setVisible(portproton_visible)
portprotonLabel.clicked.connect(lambda: self.open_portproton_forum_topic(name))
# WeAntiCheatYet бейдж # WeAntiCheatYet бейдж
anticheat_text = GameCard.getAntiCheatText(anticheat_status) anticheat_text = GameCard.getAntiCheatText(anticheat_status)
@@ -1668,11 +1681,6 @@ class MainWindow(QMainWindow):
anticheat_visible = False anticheat_visible = False
# Расположение бейджей # Расположение бейджей
right_margin = 8
badge_spacing = 5
top_y = 10
badge_y_positions = []
badge_width = int(300 * 2/3)
if steam_visible: if steam_visible:
steam_x = 300 - badge_width - right_margin steam_x = 300 - badge_width - right_margin
steamLabel.move(steam_x, top_y) steamLabel.move(steam_x, top_y)
@@ -1726,22 +1734,102 @@ class MainWindow(QMainWindow):
descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE) descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE)
detailsLayout.addWidget(descLabel) detailsLayout.addWidget(descLabel)
infoLayout = QHBoxLayout() # Инициализация HowLongToBeat
infoLayout.setSpacing(10) 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 = QLabel(_("LAST LAUNCH"))
lastLaunchTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE) lastLaunchTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
lastLaunchValue = QLabel(last_launch) lastLaunchValue = QLabel(last_launch)
lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE) lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
firstRowLayout.addWidget(lastLaunchTitle)
firstRowLayout.addWidget(lastLaunchValue)
firstRowLayout.addSpacing(30)
# Play Time
playTimeTitle = QLabel(_("PLAY TIME")) playTimeTitle = QLabel(_("PLAY TIME"))
playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE) playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE)
playTimeValue = QLabel(formatted_playtime) playTimeValue = QLabel(formatted_playtime)
playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE) playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE)
infoLayout.addWidget(lastLaunchTitle) firstRowLayout.addWidget(playTimeTitle)
infoLayout.addWidget(lastLaunchValue) firstRowLayout.addWidget(playTimeValue)
infoLayout.addSpacing(30)
infoLayout.addWidget(playTimeTitle) gameInfoLayout.addLayout(firstRowLayout)
infoLayout.addWidget(playTimeValue)
detailsLayout.addLayout(infoLayout) # Создаем 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: if controller_support:
cs = controller_support.lower() cs = controller_support.lower()
@@ -1759,7 +1847,7 @@ class MainWindow(QMainWindow):
detailsLayout.addStretch(1) detailsLayout.addStretch(1)
# Определяем текущий идентификатор игры по exec_line для корректного отображения кнопки # Определяем текущий идентификатор игры по exec_line
entry_exec_split = shlex.split(exec_line) entry_exec_split = shlex.split(exec_line)
if not entry_exec_split: if not entry_exec_split:
return return
@@ -1860,6 +1948,8 @@ class MainWindow(QMainWindow):
def goBackDetailPage(self, page: QWidget | None) -> None: def goBackDetailPage(self, page: QWidget | None) -> None:
if page is None or page != self.stackedWidget.currentWidget(): if page is None or page != self.stackedWidget.currentWidget():
return return
self._detail_page_active = False
self._current_detail_page = None
self.stackedWidget.setCurrentIndex(0) self.stackedWidget.setCurrentIndex(0)
self.stackedWidget.removeWidget(page) self.stackedWidget.removeWidget(page)
page.deleteLater() page.deleteLater()

View File

@@ -1,19 +1,58 @@
import os import os
import tarfile
import orjson
import requests import requests
import urllib.parse
import time
from collections.abc import Callable from collections.abc import Callable
from portprotonqt.downloader import Downloader, download_with_cache from portprotonqt.downloader import Downloader
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
def normalize_name(s):
"""
Приведение строки к нормальному виду:
- перевод в нижний регистр,
- удаление символов ™ и ®,
- замена разделителей (-, :, ,) на пробел,
- удаление лишних пробелов,
- удаление суффиксов 'bin' или 'app' в конце строки,
- удаление ключевых слов типа 'ultimate', 'edition' и т.п.
"""
s = s.lower()
for ch in ["", "®"]:
s = s.replace(ch, "")
for ch in ["-", ":", ","]:
s = s.replace(ch, " ")
s = " ".join(s.split())
for suffix in ["bin", "app"]:
if s.endswith(suffix):
s = s[:-len(suffix)].strip()
keywords_to_remove = {"ultimate", "edition", "definitive", "complete", "remastered"}
words = s.split()
filtered_words = [word for word in words if word not in keywords_to_remove]
return " ".join(filtered_words)
def get_cache_dir():
"""Return the cache directory path, creating it if necessary."""
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 PortProtonAPI: class PortProtonAPI:
"""API to fetch game assets (cover, metadata) from the PortProtonQt repository.""" """API to fetch game assets (cover, metadata) and forum topics from the PortProtonQt repository."""
def __init__(self, downloader: Downloader | None = None): def __init__(self, downloader: Downloader | None = None):
self.base_url = "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/custom_data" self.base_url = "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/custom_data"
self.topics_url = "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/linux_gaming_topics.tar.xz"
self.downloader = downloader or Downloader(max_workers=4) self.downloader = downloader or Downloader(max_workers=4)
self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data") self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data")
os.makedirs(self.custom_data_dir, exist_ok=True) os.makedirs(self.custom_data_dir, exist_ok=True)
self._topics_data = None
def _get_game_dir(self, exe_name: str) -> str: def _get_game_dir(self, exe_name: str) -> str:
game_dir = os.path.join(self.custom_data_dir, exe_name) game_dir = os.path.join(self.custom_data_dir, exe_name)
@@ -40,7 +79,7 @@ class PortProtonAPI:
cover_url = f"{cover_url_base}{ext}" cover_url = f"{cover_url_base}{ext}"
if self._check_file_exists(cover_url, timeout): if self._check_file_exists(cover_url, timeout):
local_cover_path = os.path.join(game_dir, f"cover{ext}") local_cover_path = os.path.join(game_dir, f"cover{ext}")
result = download_with_cache(cover_url, local_cover_path, timeout, self.downloader) result = self.downloader.download(cover_url, local_cover_path, timeout=timeout)
if result: if result:
results["cover"] = result results["cover"] = result
logger.info(f"Downloaded cover for {exe_name} to {result}") logger.info(f"Downloaded cover for {exe_name} to {result}")
@@ -52,7 +91,7 @@ class PortProtonAPI:
if self._check_file_exists(metadata_url, timeout): if self._check_file_exists(metadata_url, timeout):
local_metadata_path = os.path.join(game_dir, "metadata.txt") local_metadata_path = os.path.join(game_dir, "metadata.txt")
result = download_with_cache(metadata_url, local_metadata_path, timeout, self.downloader) result = self.downloader.download(metadata_url, local_metadata_path, timeout=timeout)
if result: if result:
results["metadata"] = result results["metadata"] = result
logger.info(f"Downloaded metadata for {exe_name} to {result}") logger.info(f"Downloaded metadata for {exe_name} to {result}")
@@ -123,3 +162,66 @@ class PortProtonAPI:
logger.debug(f"No assets found for {exe_name}") logger.debug(f"No assets found for {exe_name}")
if callback: if callback:
callback(results) callback(results)
def _load_topics_data(self):
"""Load and cache linux_gaming_topics_min.json from the archive."""
if self._topics_data is not None:
return self._topics_data
cache_dir = get_cache_dir()
cache_tar = os.path.join(cache_dir, "linux_gaming_topics.tar.xz")
cache_json = os.path.join(cache_dir, "linux_gaming_topics_min.json")
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
logger.info("Using cached topics JSON: %s", cache_json)
try:
with open(cache_json, "rb") as f:
self._topics_data = orjson.loads(f.read())
logger.debug("Loaded %d topics from cache", len(self._topics_data))
return self._topics_data
except Exception as e:
logger.error("Error reading cached topics JSON: %s", e)
self._topics_data = []
def process_tar(result: str | None):
if not result or not os.path.exists(result):
logger.error("Failed to download topics archive")
self._topics_data = []
return
try:
with tarfile.open(result, mode="r:xz") as tar:
member = next((m for m in tar.getmembers() if m.name == "linux_gaming_topics_min.json"), None)
if member is None:
raise RuntimeError("linux_gaming_topics_min.json not found in archive")
fobj = tar.extractfile(member)
if fobj is None:
raise RuntimeError("Failed to extract linux_gaming_topics_min.json from archive")
raw = fobj.read()
fobj.close()
self._topics_data = orjson.loads(raw)
with open(cache_json, "wb") as f:
f.write(orjson.dumps(self._topics_data))
if os.path.exists(cache_tar):
os.remove(cache_tar)
logger.info("Archive %s deleted after extraction", cache_tar)
logger.info("Loaded %d topics from archive", len(self._topics_data))
except Exception as e:
logger.error("Error processing topics archive: %s", e)
self._topics_data = []
self.downloader.download_async(self.topics_url, cache_tar, timeout=5, callback=process_tar)
# Wait for async download to complete if called synchronously
while self._topics_data is None:
time.sleep(0.1)
return self._topics_data
def get_forum_topic_slug(self, game_name: str) -> str:
"""Get the forum topic slug or search URL for a given game name."""
topics = self._load_topics_data()
normalized_name = normalize_name(game_name)
for topic in topics:
if topic["normalized_title"] == normalized_name:
return topic["slug"]
logger.debug("No forum topic found for game: %s, redirecting to search", game_name)
encoded_name = urllib.parse.quote(f"#ppdb {game_name}")
return f"search?q={encoded_name}"

View File

@@ -20,6 +20,8 @@ class SystemOverlay(QDialog):
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.setStyleSheet(self.theme.OVERLAY_WINDOW_STYLE) self.setStyleSheet(self.theme.OVERLAY_WINDOW_STYLE)
self.script_path = "/usr/bin/portprotonqt-session-select"
# Make window stay on top and frameless # Make window stay on top and frameless
self.setWindowFlags( self.setWindowFlags(
Qt.WindowType.FramelessWindowHint | Qt.WindowType.FramelessWindowHint |
@@ -79,8 +81,7 @@ class SystemOverlay(QDialog):
desktop_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE) desktop_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
desktop_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus) desktop_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
desktop_button.clicked.connect(self.return_to_desktop) desktop_button.clicked.connect(self.return_to_desktop)
script_path = "/usr/bin/portprotonqt-session-select" script_exists = os.path.isfile(self.script_path)
script_exists = os.path.isfile(script_path)
desktop_button.setEnabled(script_exists) desktop_button.setEnabled(script_exists)
if not script_exists: if not script_exists:
desktop_button.setToolTip(_("portprotonqt-session-select file not found at /usr/bin/")) desktop_button.setToolTip(_("portprotonqt-session-select file not found at /usr/bin/"))
@@ -139,8 +140,8 @@ class SystemOverlay(QDialog):
def return_to_desktop(self): def return_to_desktop(self):
try: try:
script_path = os.path.join(os.path.dirname(__file__), "portprotonqt-session-select") QApplication.quit()
subprocess.run([script_path, "desktop"], check=True) subprocess.run([self.script_path, "desktop"], check=True)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"Failed to return to desktop: {e}") logger.error(f"Failed to return to desktop: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to return to desktop")) QMessageBox.warning(self, _("Error"), _("Failed to return to desktop"))

View File

@@ -27,6 +27,7 @@ classifiers = [
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"babel>=2.17.0", "babel>=2.17.0",
"beautifulsoup4>=4.13.4",
"evdev>=1.9.1", "evdev>=1.9.1",
"icoextract>=0.1.6", "icoextract>=0.1.6",
"numpy>=2.2.4", "numpy>=2.2.4",

822
uv.lock generated

File diff suppressed because it is too large Load Diff