forked from Boria138/PortProtonQt
Compare commits
28 Commits
230ce904d9
...
main
Author | SHA1 | Date | |
---|---|---|---|
83076d3dfc
|
|||
04aaf68e36
|
|||
e91037708a
|
|||
1b743026c2
|
|||
30b4cec4d1
|
|||
db68c9050c
|
|||
1a93d5b82c
|
|||
cc0690cf9e
|
|||
809ba2c976
|
|||
68c9636e10
|
|||
f0df1f89be
|
|||
f25224b668
|
|||
0cda47fdfd
|
|||
1a8c733580
|
|||
2476bea32a
|
|||
1bbc95a5c1
|
|||
d12b801191
|
|||
233dab1269
|
|||
700a478598
|
|||
0fe727331f
|
|||
599644c4f6
|
|||
|
409e06f531 | ||
4818cf5b67
|
|||
59bfcdbbba
|
|||
989af36e5b
|
|||
8300857aaa
|
|||
aea1a36cfd
|
|||
f7a4fa6a17
|
@@ -9,13 +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
|
||||
|
13
LICENSE
13
LICENSE
@@ -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
|
||||
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: =
|
||||
==============
|
||||
|
81
README.md
81
README.md
@@ -4,79 +4,6 @@
|
||||
<p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
|
||||
</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)
|
||||
|
||||
```sh
|
||||
@@ -124,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) корректная работоспособность не гарантирована
|
||||
|
68
TODO.md
Normal file
68
TODO.md
Normal 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] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
|
@@ -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
|
||||
|
@@ -6,7 +6,7 @@ arch=('any')
|
||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
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'})
|
||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
|
||||
sha256sums=('SKIP')
|
||||
|
@@ -6,7 +6,7 @@ arch=('any')
|
||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
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'})
|
||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
|
||||
sha256sums=('SKIP')
|
||||
|
@@ -44,6 +44,7 @@ Requires: python3-pefile
|
||||
Requires: python3-pillow
|
||||
Requires: perl-Image-ExifTool
|
||||
Requires: xdg-utils
|
||||
Requires: python3-beautifulsoup4
|
||||
|
||||
%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.
|
||||
|
@@ -41,6 +41,7 @@ Requires: python3-pefile
|
||||
Requires: python3-pillow
|
||||
Requires: perl-Image-ExifTool
|
||||
Requires: xdg-utils
|
||||
Requires: python3-beautifulsoup4
|
||||
|
||||
%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.
|
||||
|
@@ -9,7 +9,7 @@ _portprotonqt() {
|
||||
esac
|
||||
|
||||
if [[ "$cur" == -* ]]; then
|
||||
COMPREPLY=( $( compgen -W '--fullscreen --session' -- "$cur" ) )
|
||||
COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) )
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
@@ -1405,7 +1405,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "wuthering waves",
|
||||
"status": "Planned"
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "dota underlords",
|
||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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",
|
||||
"slug": "settlement-survival"
|
||||
@@ -551,10 +683,6 @@
|
||||
"normalized_title": "snowrunner (ранее mudrunner 2)",
|
||||
"slug": "snowrunner-ranee-mudrunner-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "alan wake 2",
|
||||
"slug": "alan-wake-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "verse project",
|
||||
"slug": "verse-project"
|
||||
|
Binary file not shown.
@@ -5,12 +5,19 @@ import json
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import tarfile
|
||||
import ssl
|
||||
|
||||
# Получаем ключи и данные из переменных окружения
|
||||
STEAM_KEY = os.environ.get('STEAM_KEY')
|
||||
LINUX_GAMING_API_KEY = os.environ.get('LINUX_GAMING_API_KEY')
|
||||
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
|
||||
STEAM_BASE_URL = "https://api.steampowered.com/IStoreService/GetAppList/v1/?"
|
||||
LINUX_GAMING_BASE_URL = "https://linux-gaming.ru"
|
||||
@@ -21,6 +28,10 @@ LINUX_GAMING_HEADERS = {
|
||||
"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):
|
||||
"""
|
||||
Приведение строки к нормальному виду:
|
||||
@@ -69,7 +80,7 @@ async def get_app_list(session, last_appid, endpoint):
|
||||
url = endpoint
|
||||
if 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()
|
||||
return await response.json()
|
||||
|
||||
@@ -79,7 +90,7 @@ async def fetch_games_json(session):
|
||||
"""
|
||||
url = "https://raw.githubusercontent.com/AreWeAntiCheatYet/AreWeAntiCheatYet/HEAD/games.json"
|
||||
try:
|
||||
async with session.get(url) as response:
|
||||
async with session.get(url, verify_ssl=not DEBUG_MODE) as response:
|
||||
response.raise_for_status()
|
||||
text = await response.text()
|
||||
data = json.loads(text)
|
||||
@@ -89,52 +100,130 @@ async def fetch_games_json(session):
|
||||
return []
|
||||
|
||||
async def get_linux_gaming_topics(session, category_slug):
|
||||
"""
|
||||
Получает все темы из указанной категории linux-gaming.ru.
|
||||
Сохраняет только нормализованное название (normalized_title) и slug.
|
||||
"""
|
||||
page = 0
|
||||
all_topics = []
|
||||
max_pages = 100
|
||||
|
||||
while True:
|
||||
page += 1
|
||||
url = f"{LINUX_GAMING_BASE_URL}/c/{category_slug}/l/latest.json?page={page}"
|
||||
try:
|
||||
async with session.get(url, headers=LINUX_GAMING_HEADERS) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
topics = data.get("topic_list", {}).get("topics", [])
|
||||
if not topics:
|
||||
while page < max_pages:
|
||||
# Пробуем несколько вариантов URL
|
||||
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:
|
||||
# Добавляем параметры пагинации
|
||||
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()
|
||||
data = await response.json()
|
||||
|
||||
# Проверяем структуру ответа
|
||||
topic_list = data.get("topic_list", {})
|
||||
topics = topic_list.get("topics", [])
|
||||
|
||||
if not topics:
|
||||
if page == 0:
|
||||
if DEBUG_MODE:
|
||||
print(f"Нет тем в URL: {url}")
|
||||
continue
|
||||
else:
|
||||
print(f"Страница {page} пуста, завершаем пагинацию.")
|
||||
return all_topics
|
||||
|
||||
if DEBUG_MODE and page == 0:
|
||||
print(f"Успешно подключились к URL: {url}")
|
||||
|
||||
success = True
|
||||
break
|
||||
for topic in topics:
|
||||
all_topics.append({
|
||||
"normalized_title": normalize_name(topic["title"]),
|
||||
"slug": topic["slug"]
|
||||
})
|
||||
print(f"Обработано {len(topics)} тем на странице {page}, всего: {len(all_topics)}.")
|
||||
except Exception as error:
|
||||
print(f"Ошибка получения тем для страницы {page}: {error}")
|
||||
|
||||
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():
|
||||
"""
|
||||
Получает данные из Steam, AreWeAntiCheatYet и linux-gaming.ru,
|
||||
обрабатывает их и сохраняет в JSON-файлы и tar.xz архивы.
|
||||
"""
|
||||
# Параметры запроса для Steam
|
||||
game_param = "&include_games=true"
|
||||
dlc_param = "&include_dlc=false"
|
||||
software_param = "&include_software=false"
|
||||
videos_param = "&include_videos=false"
|
||||
hardware_param = "&include_hardware=false"
|
||||
|
||||
endpoint = (
|
||||
f"{STEAM_BASE_URL}key={STEAM_KEY}"
|
||||
f"{game_param}{dlc_param}{software_param}{videos_param}{hardware_param}"
|
||||
f"&max_results=50000"
|
||||
)
|
||||
|
||||
output_json = []
|
||||
total_parsed = 0
|
||||
linux_gaming_topics = []
|
||||
@@ -143,26 +232,48 @@ async def request_data():
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# Загружаем данные Steam
|
||||
have_more_results = True
|
||||
last_appid_val = None
|
||||
while have_more_results:
|
||||
app_list = await get_app_list(session, last_appid_val, endpoint)
|
||||
apps = app_list['response']['apps']
|
||||
apps = process_steam_apps(apps)
|
||||
output_json.extend(apps)
|
||||
total_parsed += len(apps)
|
||||
have_more_results = app_list['response'].get('have_more_results', False)
|
||||
last_appid_val = app_list['response'].get('last_appid')
|
||||
print(f"Обработано {len(apps)} игр Steam, всего: {total_parsed}.")
|
||||
if ENABLE_STEAM:
|
||||
# Параметры запроса для Steam
|
||||
game_param = "&include_games=true"
|
||||
dlc_param = "&include_dlc=false"
|
||||
software_param = "&include_software=false"
|
||||
videos_param = "&include_videos=false"
|
||||
hardware_param = "&include_hardware=false"
|
||||
|
||||
endpoint = (
|
||||
f"{STEAM_BASE_URL}key={STEAM_KEY}"
|
||||
f"{game_param}{dlc_param}{software_param}{videos_param}{hardware_param}"
|
||||
f"&max_results=50000"
|
||||
)
|
||||
|
||||
have_more_results = True
|
||||
last_appid_val = None
|
||||
while have_more_results:
|
||||
app_list = await get_app_list(session, last_appid_val, endpoint)
|
||||
apps = app_list['response']['apps']
|
||||
apps = process_steam_apps(apps)
|
||||
output_json.extend(apps)
|
||||
total_parsed += len(apps)
|
||||
have_more_results = app_list['response'].get('have_more_results', False)
|
||||
last_appid_val = app_list['response'].get('last_appid')
|
||||
print(f"Обработано {len(apps)} игр Steam, всего: {total_parsed}.")
|
||||
else:
|
||||
print("Пропущена загрузка данных Steam (ENABLE_STEAM=false).")
|
||||
|
||||
# Загружаем данные AreWeAntiCheatYet
|
||||
anticheat_games = await fetch_games_json(session)
|
||||
if ENABLE_ANTICHEAT:
|
||||
anticheat_games = await fetch_games_json(session)
|
||||
else:
|
||||
print("Пропущена загрузка данных AreWeAntiCheatYet (ENABLE_ANTICHEAT=false).")
|
||||
|
||||
# Загружаем данные linux-gaming.ru
|
||||
if LINUX_GAMING_API_KEY and LINUX_GAMING_API_USERNAME:
|
||||
linux_gaming_topics = await get_linux_gaming_topics(session, CATEGORY_LINUX_GAMING)
|
||||
if ENABLE_LINUX_GAMING:
|
||||
if LINUX_GAMING_API_KEY and LINUX_GAMING_API_USERNAME:
|
||||
linux_gaming_topics = await get_linux_gaming_topics(session, CATEGORY_LINUX_GAMING)
|
||||
else:
|
||||
print("Предупреждение: LINUX_GAMING_API_KEY или LINUX_GAMING_API_USERNAME не установлены.")
|
||||
else:
|
||||
print("Предупреждение: LINUX_GAMING_API_KEY или LINUX_GAMING_API_USERNAME не установлены.")
|
||||
print("Пропущена загрузка данных linux-gaming.ru (ENABLE_LINUX_GAMING=false).")
|
||||
|
||||
except Exception as error:
|
||||
print(f"Ошибка получения данных: {error}")
|
||||
@@ -173,55 +284,55 @@ async def request_data():
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
# Сохранение данных Steam
|
||||
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")
|
||||
with open(output_json_full, "w", encoding="utf-8") as f:
|
||||
json.dump(output_json, f, ensure_ascii=False, indent=2)
|
||||
with open(output_json_min, "w", encoding="utf-8") as f:
|
||||
json.dump(output_json, f, ensure_ascii=False, separators=(',',':'))
|
||||
if ENABLE_STEAM and output_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")
|
||||
with open(output_json_full, "w", encoding="utf-8") as f:
|
||||
json.dump(output_json, f, ensure_ascii=False, indent=2)
|
||||
with open(output_json_min, "w", encoding="utf-8") as f:
|
||||
json.dump(output_json, f, ensure_ascii=False, separators=(',',':'))
|
||||
|
||||
# Упаковка минифицированного JSON Steam в tar.xz архив
|
||||
steam_archive_path = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid.tar.xz")
|
||||
try:
|
||||
with tarfile.open(steam_archive_path, "w:xz", preset=9) as tar:
|
||||
tar.add(output_json_min, arcname=os.path.basename(output_json_min))
|
||||
print(f"Упаковано минифицированное JSON Steam в архив: {steam_archive_path}")
|
||||
os.remove(output_json_min)
|
||||
except Exception as e:
|
||||
print(f"Ошибка при упаковке архива Steam: {e}")
|
||||
return False
|
||||
|
||||
# Сохранение данных AreWeAntiCheatYet
|
||||
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=(',',':'))
|
||||
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")
|
||||
try:
|
||||
with tarfile.open(anticheat_archive_path, "w:xz", preset=9) as tar:
|
||||
tar.add(anticheat_json_min, arcname=os.path.basename(anticheat_json_min))
|
||||
print(f"Упаковано минифицированное JSON AreWeAntiCheatYet в архив: {anticheat_archive_path}")
|
||||
os.remove(anticheat_json_min)
|
||||
except Exception as e:
|
||||
print(f"Ошибка при упаковке архива AreWeAntiCheatYet: {e}")
|
||||
return False
|
||||
|
||||
# Сохранение данных 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:
|
||||
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 в tar.xz архивы
|
||||
# Архив для Steam
|
||||
steam_archive_path = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid.tar.xz")
|
||||
try:
|
||||
with tarfile.open(steam_archive_path, "w:xz", preset=9) as tar:
|
||||
tar.add(output_json_min, arcname=os.path.basename(output_json_min))
|
||||
print(f"Упаковано минифицированное JSON Steam в архив: {steam_archive_path}")
|
||||
os.remove(output_json_min)
|
||||
except Exception as e:
|
||||
print(f"Ошибка при упаковке архива Steam: {e}")
|
||||
return False
|
||||
|
||||
# Архив для AreWeAntiCheatYet
|
||||
anticheat_archive_path = os.path.join(data_dir, "anticheat_games.tar.xz")
|
||||
try:
|
||||
with tarfile.open(anticheat_archive_path, "w:xz", preset=9) as tar:
|
||||
tar.add(anticheat_json_min, arcname=os.path.basename(anticheat_json_min))
|
||||
print(f"Упаковано минифицированное JSON AreWeAntiCheatYet в архив: {anticheat_archive_path}")
|
||||
os.remove(anticheat_json_min)
|
||||
except Exception as e:
|
||||
print(f"Ошибка при упаковке архива AreWeAntiCheatYet: {e}")
|
||||
return False
|
||||
|
||||
# Архив для linux-gaming.ru
|
||||
if linux_gaming_topics:
|
||||
# Упаковка минифицированного JSON linux-gaming.ru в tar.xz архив
|
||||
linux_gaming_archive_path = os.path.join(data_dir, "linux_gaming_topics.tar.xz")
|
||||
try:
|
||||
with tarfile.open(linux_gaming_archive_path, "w:xz", preset=9) as tar:
|
||||
|
@@ -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=""):
|
||||
|
@@ -9,6 +9,8 @@ from portprotonqt.config_utils import read_favorites, save_favorites, read_displ
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
from portprotonqt.custom_widgets import ClickableLabel
|
||||
from portprotonqt.portproton_api import PortProtonAPI
|
||||
from portprotonqt.downloader import Downloader
|
||||
import weakref
|
||||
from typing import cast
|
||||
|
||||
@@ -56,6 +58,8 @@ class GameCard(QFrame):
|
||||
|
||||
self.display_filter = read_display_filter()
|
||||
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.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
|
||||
@@ -194,13 +198,13 @@ class GameCard(QFrame):
|
||||
parent=coverWidget,
|
||||
icon_size=icon_size,
|
||||
icon_space=icon_space,
|
||||
font_scale_factor=font_scale_factor,
|
||||
change_cursor=False
|
||||
font_scale_factor=font_scale_factor
|
||||
)
|
||||
self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||
self.portprotonLabel.setFixedWidth(badge_width)
|
||||
self.portprotonLabel.setCardWidth(card_width)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic)
|
||||
|
||||
# WeAntiCheatYet бейдж
|
||||
anticheat_text = self.getAntiCheatText(anticheat_status)
|
||||
@@ -385,6 +389,16 @@ class GameCard(QFrame):
|
||||
return "broken"
|
||||
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):
|
||||
url = QUrl(f"https://www.protondb.com/app/{self.appid}")
|
||||
QDesktopServices.openUrl(url)
|
||||
|
371
portprotonqt/howlongtobeat_api.py
Normal file
371
portprotonqt/howlongtobeat_api.py
Normal 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()
|
@@ -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,
|
||||
@@ -248,6 +249,16 @@ class MainWindow(QMainWindow):
|
||||
self.updateGameGrid()
|
||||
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):
|
||||
"""Обработчик сигнала focusChanged от GameCard."""
|
||||
card_key = None
|
||||
@@ -1507,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):
|
||||
@@ -1579,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)
|
||||
@@ -1639,11 +1652,11 @@ class MainWindow(QMainWindow):
|
||||
parent=coverFrame,
|
||||
icon_size=16,
|
||||
icon_space=5,
|
||||
change_cursor=False
|
||||
)
|
||||
portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||
portprotonLabel.setFixedWidth(badge_width)
|
||||
portprotonLabel.setVisible(portproton_visible)
|
||||
portprotonLabel.clicked.connect(lambda: self.open_portproton_forum_topic(name))
|
||||
|
||||
# WeAntiCheatYet бейдж
|
||||
anticheat_text = GameCard.getAntiCheatText(anticheat_status)
|
||||
@@ -1668,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)
|
||||
@@ -1726,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()
|
||||
@@ -1759,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
|
||||
@@ -1860,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()
|
||||
|
@@ -1,19 +1,58 @@
|
||||
import os
|
||||
import tarfile
|
||||
import orjson
|
||||
import requests
|
||||
import urllib.parse
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from portprotonqt.downloader import Downloader, download_with_cache
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
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:
|
||||
"""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):
|
||||
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.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")
|
||||
os.makedirs(self.custom_data_dir, exist_ok=True)
|
||||
self._topics_data = None
|
||||
|
||||
def _get_game_dir(self, exe_name: str) -> str:
|
||||
game_dir = os.path.join(self.custom_data_dir, exe_name)
|
||||
@@ -40,7 +79,7 @@ class PortProtonAPI:
|
||||
cover_url = f"{cover_url_base}{ext}"
|
||||
if self._check_file_exists(cover_url, timeout):
|
||||
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:
|
||||
results["cover"] = 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):
|
||||
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:
|
||||
results["metadata"] = 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}")
|
||||
if callback:
|
||||
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}"
|
||||
|
@@ -20,6 +20,8 @@ class SystemOverlay(QDialog):
|
||||
self.theme_manager = ThemeManager()
|
||||
self.setStyleSheet(self.theme.OVERLAY_WINDOW_STYLE)
|
||||
|
||||
self.script_path = "/usr/bin/portprotonqt-session-select"
|
||||
|
||||
# Make window stay on top and frameless
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.FramelessWindowHint |
|
||||
@@ -79,8 +81,7 @@ class SystemOverlay(QDialog):
|
||||
desktop_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
||||
desktop_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
desktop_button.clicked.connect(self.return_to_desktop)
|
||||
script_path = "/usr/bin/portprotonqt-session-select"
|
||||
script_exists = os.path.isfile(script_path)
|
||||
script_exists = os.path.isfile(self.script_path)
|
||||
desktop_button.setEnabled(script_exists)
|
||||
if not script_exists:
|
||||
desktop_button.setToolTip(_("portprotonqt-session-select file not found at /usr/bin/"))
|
||||
@@ -139,8 +140,8 @@ class SystemOverlay(QDialog):
|
||||
|
||||
def return_to_desktop(self):
|
||||
try:
|
||||
script_path = os.path.join(os.path.dirname(__file__), "portprotonqt-session-select")
|
||||
subprocess.run([script_path, "desktop"], check=True)
|
||||
QApplication.quit()
|
||||
subprocess.run([self.script_path, "desktop"], check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Failed to return to desktop: {e}")
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to return to desktop"))
|
||||
|
@@ -27,6 +27,7 @@ classifiers = [
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"babel>=2.17.0",
|
||||
"beautifulsoup4>=4.13.4",
|
||||
"evdev>=1.9.1",
|
||||
"icoextract>=0.1.6",
|
||||
"numpy>=2.2.4",
|
||||
|
Reference in New Issue
Block a user