forked from Boria138/PortProtonQt
		
	Compare commits
	
		
			8 Commits
		
	
	
		
			230ce904d9
			...
			599644c4f6
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 599644c4f6 | |||
|  | 409e06f531 | ||
| 4818cf5b67 | |||
| 59bfcdbbba | |||
| 989af36e5b | |||
| 8300857aaa | |||
| aea1a36cfd | |||
| f7a4fa6a17 | 
| @@ -13,6 +13,7 @@ | ||||
| ### Changed | ||||
| - Оптимизированны обложки автоинсталлов | ||||
| - Папка custom_data исключена из сборки модуля для уменьшение его размера | ||||
| - Бейдж PortProton теперь открывает PortProtonDB | ||||
|  | ||||
| ### Fixed | ||||
| - Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси | ||||
|   | ||||
							
								
								
									
										73
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										73
									
								
								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 | ||||
|   | ||||
							
								
								
									
										69
									
								
								TODO.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								TODO.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| - [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 (оставлено для [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] Добавить переводы в переопределения | ||||
| - [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] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -248,6 +248,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 | ||||
| @@ -1639,11 +1649,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) | ||||
|   | ||||
| @@ -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}" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user