4 Commits

Author SHA1 Message Date
1a8c733580 chore(todo): update
All checks were successful
Check Translations / check-translations (push) Successful in 17s
Code and build check / Check code (push) Successful in 1m35s
Code and build check / Build with uv (push) Successful in 53s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 13:23:44 +05:00
2476bea32a chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 13:19:36 +05:00
1bbc95a5c1 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 13:18:40 +05:00
d12b801191 feat: added data from How Long To Beat to GameCard
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 13:15:17 +05:00
13 changed files with 260 additions and 241 deletions

View File

@@ -9,6 +9,7 @@
- Переводы в переопределениях (за подробностями в документацию) - Переводы в переопределениях (за подробностями в документацию)
- Обложки и описания для всех автоинсталлов - Обложки и описания для всех автоинсталлов
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры - Возможность указать ссылку для скачивания обложки в диалоге добавления игры
- Интеграция с howlongtobeat.com
### Changed ### Changed
- Оптимизированны обложки автоинсталлов - Оптимизированны обложки автоинсталлов

View File

@@ -17,7 +17,7 @@
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода - [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки) - [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
- [X] Избавиться от вызовов yad - [X] Избавиться от вызовов yad
- [X] Реализовать собственный механизм запрета ухода в спящий режим вместо использования механизма PortProton (оставлено для [PortProton 2.0](https://github.com/Castro-Fidel/PortProton_2.0)) - [ ] Реализовать собственный механизм запрета ухода в спящий режим вместо использования механизма PortProton
- [X] Реализовать собственный системный трей вместо использования трея PortProton - [X] Реализовать собственный системный трей вместо использования трея PortProton
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.) - [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту) - [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
@@ -42,6 +42,7 @@
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql) - [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam - [X] Добавить на карточку бейдж, указывающий, что игра из Steam
- [X] Добавить поддержку версий Steam для Flatpak и Snap - [X] Добавить поддержку версий Steam для Flatpak и Snap
- [ ] Реализовать добавлление игры как сторонней в Steam без
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся - [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад» - [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide) - [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
@@ -57,7 +58,7 @@
- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме - [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
- [ ] Добавить поддержку GOG (?) - [ ] Добавить поддержку GOG (?)
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант) - [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
- [ ] Добавить данные с HowLongToBeat на страницу с деталями игры (?) - [X] Добавить данные с HowLongToBeat на страницу с деталями игры
- [X] Добавить виброотдачу на геймпаде при запуске игры - [X] Добавить виброотдачу на геймпаде при запуске игры
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63) - [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)) - [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))

View File

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

View File

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

View File

@@ -1,71 +1,37 @@
import orjson import orjson
import re import re
import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum
from typing import Any from typing import Any
from difflib import SequenceMatcher from difflib import SequenceMatcher
from threading import Thread
import requests import requests
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
from portprotonqt.config_utils import read_proxy_config from portprotonqt.config_utils import read_proxy_config
from portprotonqt.time_utils import format_playtime
from PySide6.QtCore import QObject, Signal
class SearchModifiers(Enum):
"""Модификаторы поиска для фильтрации результатов."""
NONE = ""
ONLY_DLC = "only_dlc"
ONLY_MODS = "only_mods"
ONLY_HACKS = "only_hacks"
HIDE_DLC = "hide_dlc"
@dataclass @dataclass
class GameEntry: class GameEntry:
"""Информация об игре из HowLongToBeat.""" """Информация об игре из HowLongToBeat."""
# Основная информация
game_id: int = -1 game_id: int = -1
game_name: str | None = None game_name: str | None = None
game_alias: str | None = None
game_type: str | None = None
game_image_url: str | None = None
game_web_link: str | None = None
review_score: float | None = None
developer: str | None = None
platforms: list[str] = field(default_factory=list)
release_year: int | None = None
similarity: float = -1.0
# Времена прохождения (в часах)
main_story: float | None = None main_story: float | None = None
main_extra: float | None = None main_extra: float | None = None
completionist: float | None = None completionist: float | None = None
all_styles: float | None = None similarity: float = -1.0
coop_time: float | None = None
multiplayer_time: float | None = None
# Флаги сложности
has_single_player: bool = False
has_coop: bool = False
has_multiplayer: bool = False
has_combined_complexity: bool = False
# Исходные данные JSON
raw_data: dict[str, Any] = field(default_factory=dict) raw_data: dict[str, Any] = field(default_factory=dict)
@dataclass @dataclass
class SearchConfig: class SearchConfig:
"""Конфигурация для поиска.""" """Конфигурация для поиска."""
api_key: str | None = None api_key: str | None = None
search_url: str | None = None search_url: str | None = None
class APIKeyExtractor: class APIKeyExtractor:
"""Извлекает API ключ и URL поиска из скриптов сайта.""" """Извлекает API ключ и URL поиска из скриптов сайта."""
@staticmethod @staticmethod
def extract_from_script(script_content: str) -> SearchConfig: def extract_from_script(script_content: str) -> SearchConfig:
"""Извлекает конфигурацию из содержимого скрипта."""
config = SearchConfig() config = SearchConfig()
config.api_key = APIKeyExtractor._extract_api_key(script_content) config.api_key = APIKeyExtractor._extract_api_key(script_content)
config.search_url = APIKeyExtractor._extract_search_url(script_content, config.api_key) config.search_url = APIKeyExtractor._extract_search_url(script_content, config.api_key)
@@ -73,53 +39,40 @@ class APIKeyExtractor:
@staticmethod @staticmethod
def _extract_api_key(script_content: str) -> str | None: def _extract_api_key(script_content: str) -> str | None:
"""Извлекает API ключ из скрипта."""
# Паттерн для поиска user ID
user_id_pattern = r'users\s*:\s*{\s*id\s*:\s*"([^"]+)"' user_id_pattern = r'users\s*:\s*{\s*id\s*:\s*"([^"]+)"'
matches = re.findall(user_id_pattern, script_content) matches = re.findall(user_id_pattern, script_content)
if matches: if matches:
return ''.join(matches) return ''.join(matches)
# Паттерн для поиска конкатенированного API ключа
concat_pattern = r'\/api\/\w+\/"(?:\.concat\("[^"]*"\))+' concat_pattern = r'\/api\/\w+\/"(?:\.concat\("[^"]*"\))+'
matches = re.findall(concat_pattern, script_content) matches = re.findall(concat_pattern, script_content)
if matches: if matches:
parts = str(matches).split('.concat') parts = str(matches).split('.concat')
cleaned_parts = [re.sub(r'["\(\)\[\]\']', '', part) for part in parts[1:]] cleaned_parts = [re.sub(r'["\(\)\[\]\']', '', part) for part in parts[1:]]
return ''.join(cleaned_parts) return ''.join(cleaned_parts)
return None return None
@staticmethod @staticmethod
def _extract_search_url(script_content: str, api_key: str | None) -> str | None: def _extract_search_url(script_content: str, api_key: str | None) -> str | None:
"""Извлекает URL поиска из скрипта."""
if not api_key: if not api_key:
return None return None
pattern = re.compile( pattern = re.compile(
r'fetch\(\s*["\'](\/api\/[^"\']*)["\']' r'fetch\(\s*["\'](\/api\/[^"\']*)["\']'
r'((?:\s*\.concat\(\s*["\']([^"\']*)["\']\s*\))+)' r'((?:\s*\.concat\(\s*["\']([^"\']*)["\']\s*\))+)'
r'\s*,', r'\s*,',
re.DOTALL re.DOTALL
) )
for match in pattern.finditer(script_content): for match in pattern.finditer(script_content):
endpoint = match.group(1) endpoint = match.group(1)
concat_calls = match.group(2) concat_calls = match.group(2)
concat_strings = re.findall(r'\.concat\(\s*["\']([^"\']*)["\']\s*\)', concat_calls) concat_strings = re.findall(r'\.concat\(\s*["\']([^"\']*)["\']\s*\)', concat_calls)
concatenated_str = ''.join(concat_strings) concatenated_str = ''.join(concat_strings)
if concatenated_str == api_key: if concatenated_str == api_key:
return endpoint return endpoint
return None return None
class HTTPClient: class HTTPClient:
"""HTTP клиент для работы с API HowLongToBeat.""" """HTTP клиент для работы с API HowLongToBeat."""
BASE_URL = 'https://howlongtobeat.com/' BASE_URL = 'https://howlongtobeat.com/'
GAME_URL = BASE_URL + "game"
SEARCH_URL = BASE_URL + "api/s/" SEARCH_URL = BASE_URL + "api/s/"
def __init__(self, timeout: int = 60): def __init__(self, timeout: int = 60):
@@ -129,35 +82,23 @@ class HTTPClient:
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', '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 'referer': self.BASE_URL
}) })
# Apply proxy settings from config
proxy_config = read_proxy_config() proxy_config = read_proxy_config()
if proxy_config: if proxy_config:
self.session.proxies.update(proxy_config) self.session.proxies.update(proxy_config)
def get_search_config(self, parse_all_scripts: bool = False) -> SearchConfig | None: def get_search_config(self, parse_all_scripts: bool = False) -> SearchConfig | None:
"""Получает конфигурацию поиска с главной страницы."""
try: try:
response = self.session.get(self.BASE_URL, timeout=self.timeout) response = self.session.get(self.BASE_URL, timeout=self.timeout)
response.raise_for_status() response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser') soup = BeautifulSoup(response.text, 'html.parser')
scripts = soup.find_all('script', src=True) scripts = soup.find_all('script', src=True)
# Filter for Tag objects and ensure src is a string
if parse_all_scripts:
script_urls = [] script_urls = []
for script in scripts: for script in scripts:
if isinstance(script, Tag): if isinstance(script, Tag):
src = script.get('src') src = script.get('src')
if src is not None and isinstance(src, str): if src is not None and isinstance(src, str):
if parse_all_scripts or '_app-' in src:
script_urls.append(src) script_urls.append(src)
else:
script_urls = []
for script in scripts:
if isinstance(script, Tag):
src = script.get('src')
if src is not None and isinstance(src, str) and '_app-' in src:
script_urls.append(src)
for script_url in script_urls: for script_url in script_urls:
full_url = self.BASE_URL + script_url full_url = self.BASE_URL + script_url
script_response = self.session.get(full_url, timeout=self.timeout) script_response = self.session.get(full_url, timeout=self.timeout)
@@ -169,28 +110,21 @@ class HTTPClient:
pass pass
return None return None
def search_games(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE, def search_games(self, game_name: str, page: int = 1, config: SearchConfig | None = None) -> str | None:
page: int = 1, config: SearchConfig | None = None) -> str | None:
"""Выполняет поиск игр."""
if not config: if not config:
config = self.get_search_config() config = self.get_search_config()
if not config: if not config:
config = self.get_search_config(parse_all_scripts=True) config = self.get_search_config(parse_all_scripts=True)
if not config or not config.api_key: if not config or not config.api_key:
return None return None
search_url = self.SEARCH_URL search_url = self.SEARCH_URL
if config.search_url: if config.search_url:
search_url = self.BASE_URL + config.search_url.lstrip('/') search_url = self.BASE_URL + config.search_url.lstrip('/')
payload = self._build_search_payload(game_name, page, config)
payload = self._build_search_payload(game_name, search_modifiers, page, config)
headers = { headers = {
'content-type': 'application/json', 'content-type': 'application/json',
'accept': '*/*' 'accept': '*/*'
} }
# Попытка с API ключом в URL
try: try:
response = self.session.post( response = self.session.post(
search_url + config.api_key, search_url + config.api_key,
@@ -202,8 +136,6 @@ class HTTPClient:
return response.text return response.text
except requests.RequestException: except requests.RequestException:
pass pass
# Попытка с API ключом в payload
try: try:
response = self.session.post( response = self.session.post(
search_url, search_url,
@@ -215,37 +147,14 @@ class HTTPClient:
return response.text return response.text
except requests.RequestException: except requests.RequestException:
pass pass
return None return None
def get_game_title(self, game_id: int) -> str | None: def _build_search_payload(self, game_name: str, page: int, config: SearchConfig) -> dict[str, Any]:
"""Получает название игры по ID."""
try:
params = {'id': str(game_id)}
response = self.session.get(self.GAME_URL, params=params, timeout=self.timeout)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
title_tag = soup.title
if title_tag and title_tag.string:
# Обрезаем стандартные части заголовка
title = title_tag.string[12:-17].strip()
return title
except requests.RequestException:
pass
return None
def _build_search_payload(self, game_name: str, search_modifiers: SearchModifiers,
page: int, config: SearchConfig) -> dict[str, Any]:
"""Строит payload для поискового запроса."""
payload = { payload = {
'searchType': "games", 'searchType': "games",
'searchTerms': game_name.split(), 'searchTerms': game_name.split(),
'searchPage': page, 'searchPage': page,
'size': 20, 'size': 1, # Limit to 1 result
'searchOptions': { 'searchOptions': {
'games': { 'games': {
'userId': 0, 'userId': 0,
@@ -260,7 +169,7 @@ class HTTPClient:
"difficulty": "" "difficulty": ""
}, },
'rangeYear': {'max': "", 'min': ""}, 'rangeYear': {'max': "", 'min': ""},
'modifier': search_modifiers.value, 'modifier': "" # Hardcoded to empty string for SearchModifiers.NONE
}, },
'users': {'sortCategory': "postcount"}, 'users': {'sortCategory': "postcount"},
'lists': {'sortCategory': "follows"}, 'lists': {'sortCategory': "follows"},
@@ -268,194 +177,195 @@ class HTTPClient:
'sort': 0, 'sort': 0,
'randomizer': 0 'randomizer': 0
}, },
'useCache': True 'useCache': True,
'fields': ["game_id", "game_name", "comp_main", "comp_plus", "comp_100"] # Request only needed fields
} }
if config.api_key: if config.api_key:
payload['searchOptions']['users']['id'] = config.api_key payload['searchOptions']['users']['id'] = config.api_key
return payload return payload
class ResultParser: class ResultParser:
"""Парсер результатов поиска.""" """Парсер результатов поиска."""
def __init__(self, search_query: str, minimum_similarity: float = 0.4, case_sensitive: bool = True):
IMAGE_URL_PREFIX = "https://howlongtobeat.com/games/"
GAME_URL_PREFIX = "https://howlongtobeat.com/game/"
def __init__(self, search_query: str, minimum_similarity: float = 0.4,
case_sensitive: bool = True, auto_filter_times: bool = False):
self.search_query = search_query self.search_query = search_query
self.minimum_similarity = minimum_similarity self.minimum_similarity = minimum_similarity
self.case_sensitive = case_sensitive self.case_sensitive = case_sensitive
self.auto_filter_times = auto_filter_times
self.search_numbers = self._extract_numbers(search_query) self.search_numbers = self._extract_numbers(search_query)
def parse_results(self, json_response: str, target_game_id: int | None = None) -> list[GameEntry]: def parse_results(self, json_response: str, target_game_id: int | None = None) -> list[GameEntry]:
"""Парсит JSON ответ и возвращает список игр."""
try: try:
data = orjson.loads(json_response) data = orjson.loads(json_response)
games = [] games = []
# Only process the first result
for game_data in data.get("data", []): if data.get("data"):
game_data = data["data"][0]
game = self._parse_game_entry(game_data) game = self._parse_game_entry(game_data)
if target_game_id is not None: if target_game_id is not None:
if game.game_id == target_game_id: if game.game_id == target_game_id:
games.append(game) games.append(game)
elif self.minimum_similarity == 0.0 or game.similarity >= self.minimum_similarity: elif self.minimum_similarity == 0.0 or game.similarity >= self.minimum_similarity:
games.append(game) games.append(game)
return games return games
except (orjson.JSONDecodeError, KeyError, IndexError):
except (orjson.JSONDecodeError, KeyError):
return [] return []
def _parse_game_entry(self, game_data: dict[str, Any]) -> GameEntry: def _parse_game_entry(self, game_data: dict[str, Any]) -> GameEntry:
"""Парсит данные одной игры."""
game = GameEntry() game = GameEntry()
# Основная информация
game.game_id = game_data.get("game_id", -1) game.game_id = game_data.get("game_id", -1)
game.game_name = game_data.get("game_name") game.game_name = game_data.get("game_name")
game.game_alias = game_data.get("game_alias")
game.game_type = game_data.get("game_type")
game.review_score = game_data.get("review_score")
game.developer = game_data.get("profile_dev")
game.release_year = game_data.get("release_world")
game.raw_data = game_data game.raw_data = game_data
# URL изображения
if "game_image" in game_data:
game.game_image_url = self.IMAGE_URL_PREFIX + game_data["game_image"]
# Ссылка на игру
game.game_web_link = f"{self.GAME_URL_PREFIX}{game.game_id}"
# Платформы
if "profile_platform" in game_data:
game.platforms = game_data["profile_platform"].split(", ")
# Времена прохождения (конвертация из секунд в часы)
time_fields = [ time_fields = [
("comp_main", "main_story"), ("comp_main", "main_story"),
("comp_plus", "main_extra"), ("comp_plus", "main_extra"),
("comp_100", "completionist"), ("comp_100", "completionist")
("comp_all", "all_styles"),
("invested_co", "coop_time"),
("invested_mp", "multiplayer_time")
] ]
for json_field, attr_name in time_fields: for json_field, attr_name in time_fields:
if json_field in game_data: if json_field in game_data:
time_hours = round(game_data[json_field] / 3600, 2) time_hours = round(game_data[json_field] / 3600, 2)
setattr(game, attr_name, time_hours) setattr(game, attr_name, time_hours)
# Флаги сложности
game.has_combined_complexity = bool(game_data.get("comp_lvl_combine", 0))
game.has_single_player = bool(game_data.get("comp_lvl_sp", 0))
game.has_coop = bool(game_data.get("comp_lvl_co", 0))
game.has_multiplayer = bool(game_data.get("comp_lvl_mp", 0))
# Автофильтрация времен
if self.auto_filter_times:
if not game.has_single_player:
game.main_story = None
game.main_extra = None
game.completionist = None
game.all_styles = None
if not game.has_coop:
game.coop_time = None
if not game.has_multiplayer:
game.multiplayer_time = None
# Вычисление similarity
game.similarity = self._calculate_similarity(game) game.similarity = self._calculate_similarity(game)
return game return game
def _calculate_similarity(self, game: GameEntry) -> float: def _calculate_similarity(self, game: GameEntry) -> float:
"""Вычисляет similarity между поисковым запросом и игрой.""" return self._compare_strings(self.search_query, game.game_name)
name_similarity = self._compare_strings(self.search_query, game.game_name)
alias_similarity = self._compare_strings(self.search_query, game.game_alias)
return max(name_similarity, alias_similarity)
def _compare_strings(self, a: str | None, b: str | None) -> float: def _compare_strings(self, a: str | None, b: str | None) -> float:
"""Сравнивает две строки и возвращает коэффициент similarity."""
if not a or not b: if not a or not b:
return 0.0 return 0.0
if self.case_sensitive: if self.case_sensitive:
similarity = SequenceMatcher(None, a, b).ratio() similarity = SequenceMatcher(None, a, b).ratio()
else: else:
similarity = SequenceMatcher(None, a.lower(), b.lower()).ratio() similarity = SequenceMatcher(None, a.lower(), b.lower()).ratio()
# Штраф за отсутствие чисел из оригинального запроса
if self.search_numbers and not self._contains_numbers(b, self.search_numbers): if self.search_numbers and not self._contains_numbers(b, self.search_numbers):
similarity -= 0.1 similarity -= 0.1
return max(0.0, similarity) return max(0.0, similarity)
@staticmethod @staticmethod
def _extract_numbers(text: str) -> list[str]: def _extract_numbers(text: str) -> list[str]:
"""Извлекает числа из текста."""
return [word for word in text.split() if word.isdigit()] return [word for word in text.split() if word.isdigit()]
@staticmethod @staticmethod
def _contains_numbers(text: str, numbers: list[str]) -> bool: def _contains_numbers(text: str, numbers: list[str]) -> bool:
"""Проверяет, содержит ли текст указанные числа."""
if not numbers: if not numbers:
return True return True
cleaned_text = re.sub(r'([^\s\w]|_)+', '', text) cleaned_text = re.sub(r'([^\s\w]|_)+', '', text)
text_numbers = [word for word in cleaned_text.split() if word.isdigit()] text_numbers = [word for word in cleaned_text.split() if word.isdigit()]
return any(num in text_numbers for num in numbers) return any(num in text_numbers for num in numbers)
def get_cache_dir():
"""Возвращает путь к каталогу кэша, создаёт его при необходимости."""
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
os.makedirs(cache_dir, exist_ok=True)
return cache_dir
class HowLongToBeat: class HowLongToBeat(QObject):
"""Основной класс для работы с API HowLongToBeat.""" """Основной класс для работы с API HowLongToBeat."""
searchCompleted = Signal(list)
def __init__(self, minimum_similarity: float = 0.4, auto_filter_times: bool = False, def __init__(self, minimum_similarity: float = 0.4, timeout: int = 60, parent=None):
timeout: int = 60): super().__init__(parent)
self.minimum_similarity = minimum_similarity self.minimum_similarity = minimum_similarity
self.auto_filter_times = auto_filter_times
self.http_client = HTTPClient(timeout) self.http_client = HTTPClient(timeout)
self.cache_dir = get_cache_dir()
def search(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE, def _get_cache_file_path(self, game_name: str) -> str:
case_sensitive: bool = True) -> list[GameEntry] | None: """Возвращает путь к файлу кэша для заданного имени игры."""
"""Ищет игры по названию.""" 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(): if not game_name or not game_name.strip():
return None return None
# Проверяем кэш
json_response = self.http_client.search_games(game_name, search_modifiers) cached_response = self._load_from_cache(game_name)
if not json_response: if cached_response:
return None 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( parser = ResultParser(
game_name, game_name,
self.minimum_similarity, self.minimum_similarity,
case_sensitive, case_sensitive
self.auto_filter_times
) )
return parser.parse_results(orjson.dumps(full_json).decode('utf-8'))
return parser.parse_results(json_response) except orjson.JSONDecodeError:
pass
def search_by_id(self, game_id: int) -> GameEntry | None: # Если нет в кэше, делаем запрос
"""Ищет игру по ID.""" json_response = self.http_client.search_games(game_name)
if not game_id or game_id <= 0:
return None
game_title = self.http_client.get_game_title(game_id)
if not game_title:
return None
json_response = self.http_client.search_games(game_title)
if not json_response: if not json_response:
return None 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)
parser = ResultParser(game_title, 0.0, False, self.auto_filter_times) def format_game_time(self, game_entry: GameEntry, time_field: str = "main_story") -> str | None:
results = parser.parse_results(json_response, target_game_id=game_id) 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)
return results[0] if results else None def search_with_callback(self, game_name: str, case_sensitive: bool = True):
"""Выполняет поиск игры в фоновом потоке и испускает сигнал с результатами."""
def search_thread():
try:
results = self.search(game_name, case_sensitive)
self.searchCompleted.emit(results if results else [])
except Exception as e:
print(f"Error in search_with_callback: {e}")
self.searchCompleted.emit([])
thread = Thread(target=search_thread)
thread.daemon = True
thread.start()

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-06 17:56+0500\n" "POT-Creation-Date: 2025-07-14 13:16+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n" "Language: de_DE\n"
@@ -563,6 +563,15 @@ msgstr ""
msgid "PLAY TIME" msgid "PLAY TIME"
msgstr "" msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full" msgid "full"
msgstr "" msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-06 17:56+0500\n" "POT-Creation-Date: 2025-07-14 13:16+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n" "Language: es_ES\n"
@@ -563,6 +563,15 @@ msgstr ""
msgid "PLAY TIME" msgid "PLAY TIME"
msgstr "" msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full" msgid "full"
msgstr "" msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n" "Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-06 17:56+0500\n" "POT-Creation-Date: 2025-07-14 13:16+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -561,6 +561,15 @@ msgstr ""
msgid "PLAY TIME" msgid "PLAY TIME"
msgstr "" msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full" msgid "full"
msgstr "" msgstr ""

View File

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

View File

@@ -31,6 +31,7 @@ from portprotonqt.config_utils import (
) )
from portprotonqt.localization import _, get_egs_language, read_metadata_translations from portprotonqt.localization import _, get_egs_language, read_metadata_translations
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider, from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
@@ -1589,7 +1590,7 @@ class MainWindow(QMainWindow):
badge_spacing = 5 badge_spacing = 5
top_y = 10 top_y = 10
badge_y_positions = [] badge_y_positions = []
badge_width = int(300 * 2/3) # 2/3 ширины обложки (300 px) badge_width = int(300 * 2/3)
# ProtonDB бейдж # ProtonDB бейдж
protondb_text = GameCard.getProtonDBText(protondb_tier) protondb_text = GameCard.getProtonDBText(protondb_tier)
@@ -1678,11 +1679,6 @@ class MainWindow(QMainWindow):
anticheat_visible = False anticheat_visible = False
# Расположение бейджей # Расположение бейджей
right_margin = 8
badge_spacing = 5
top_y = 10
badge_y_positions = []
badge_width = int(300 * 2/3)
if steam_visible: if steam_visible:
steam_x = 300 - badge_width - right_margin steam_x = 300 - badge_width - right_margin
steamLabel.move(steam_x, top_y) steamLabel.move(steam_x, top_y)
@@ -1736,22 +1732,97 @@ class MainWindow(QMainWindow):
descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE) descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE)
detailsLayout.addWidget(descLabel) detailsLayout.addWidget(descLabel)
infoLayout = QHBoxLayout() # Инициализация HowLongToBeat
infoLayout.setSpacing(10) hltb = HowLongToBeat(parent=self)
# Создаем общий layout для всей игровой информации
gameInfoLayout = QVBoxLayout()
gameInfoLayout.setSpacing(10)
# Первая строка: Last Launch и Play Time
firstRowLayout = QHBoxLayout()
firstRowLayout.setSpacing(10)
# Last Launch
lastLaunchTitle = QLabel(_("LAST LAUNCH")) lastLaunchTitle = QLabel(_("LAST LAUNCH"))
lastLaunchTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE) lastLaunchTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
lastLaunchValue = QLabel(last_launch) lastLaunchValue = QLabel(last_launch)
lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE) lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
firstRowLayout.addWidget(lastLaunchTitle)
firstRowLayout.addWidget(lastLaunchValue)
firstRowLayout.addSpacing(30)
# Play Time
playTimeTitle = QLabel(_("PLAY TIME")) playTimeTitle = QLabel(_("PLAY TIME"))
playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE) playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE)
playTimeValue = QLabel(formatted_playtime) playTimeValue = QLabel(formatted_playtime)
playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE) playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE)
infoLayout.addWidget(lastLaunchTitle) firstRowLayout.addWidget(playTimeTitle)
infoLayout.addWidget(lastLaunchValue) firstRowLayout.addWidget(playTimeValue)
infoLayout.addSpacing(30)
infoLayout.addWidget(playTimeTitle) gameInfoLayout.addLayout(firstRowLayout)
infoLayout.addWidget(playTimeValue)
detailsLayout.addLayout(infoLayout) # Создаем placeholder для второй строки (HLTB данные)
hltbLayout = QHBoxLayout()
hltbLayout.setSpacing(10)
# Время прохождения (Main Story, Main + Sides, Completionist)
def on_hltb_results(results):
if results:
game = results[0] # Берем первый результат
main_story_time = hltb.format_game_time(game, "main_story")
main_extra_time = hltb.format_game_time(game, "main_extra")
completionist_time = hltb.format_game_time(game, "completionist")
# Очищаем layout перед добавлением новых элементов
while hltbLayout.count():
child = hltbLayout.takeAt(0)
if child.widget():
child.widget().deleteLater()
has_data = False
if main_story_time is not None:
mainStoryTitle = QLabel(_("MAIN STORY"))
mainStoryTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
mainStoryValue = QLabel(main_story_time)
mainStoryValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
hltbLayout.addWidget(mainStoryTitle)
hltbLayout.addWidget(mainStoryValue)
hltbLayout.addSpacing(30)
has_data = True
if main_extra_time is not None:
mainExtraTitle = QLabel(_("MAIN + SIDES"))
mainExtraTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE)
mainExtraValue = QLabel(main_extra_time)
mainExtraValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE)
hltbLayout.addWidget(mainExtraTitle)
hltbLayout.addWidget(mainExtraValue)
hltbLayout.addSpacing(30)
has_data = True
if completionist_time is not None:
completionistTitle = QLabel(_("COMPLETIONIST"))
completionistTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
completionistValue = QLabel(completionist_time)
completionistValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
hltbLayout.addWidget(completionistTitle)
hltbLayout.addWidget(completionistValue)
has_data = True
# Если есть данные, добавляем layout во вторую строку
if has_data:
gameInfoLayout.addLayout(hltbLayout)
# Подключаем сигнал searchCompleted к on_hltb_results
hltb.searchCompleted.connect(on_hltb_results)
# Запускаем поиск в фоновом потоке
hltb.search_with_callback(name, case_sensitive=False)
# Добавляем общий layout с игровой информацией
detailsLayout.addLayout(gameInfoLayout)
if controller_support: if controller_support:
cs = controller_support.lower() cs = controller_support.lower()
@@ -1769,7 +1840,7 @@ class MainWindow(QMainWindow):
detailsLayout.addStretch(1) detailsLayout.addStretch(1)
# Определяем текущий идентификатор игры по exec_line для корректного отображения кнопки # Определяем текущий идентификатор игры по exec_line
entry_exec_split = shlex.split(exec_line) entry_exec_split = shlex.split(exec_line)
if not entry_exec_split: if not entry_exec_split:
return return