feat: added data from How Long To Beat to GameCard

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-07-14 13:15:17 +05:00
parent 233dab1269
commit d12b801191
2 changed files with 209 additions and 228 deletions

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)
script_urls = []
# Filter for Tag objects and ensure src is a string for script in scripts:
if parse_all_scripts: if isinstance(script, Tag):
script_urls = [] src = script.get('src')
for script in scripts: if src is not None and isinstance(src, str):
if isinstance(script, Tag): if parse_all_scripts or '_app-' in src:
src = script.get('src')
if src is not None and isinstance(src, str):
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 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: if not json_response:
return None return None
# Сохраняем в кэш только первую игру
self._save_to_cache(game_name, json_response)
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(json_response) return parser.parse_results(json_response)
def search_by_id(self, game_id: int) -> GameEntry | None: def format_game_time(self, game_entry: GameEntry, time_field: str = "main_story") -> str | None:
"""Ищет игру по ID.""" time_value = getattr(game_entry, time_field, None)
if not game_id or game_id <= 0: if time_value is None:
return None return None
time_seconds = int(time_value * 3600)
return format_playtime(time_seconds)
game_title = self.http_client.get_game_title(game_id) def search_with_callback(self, game_name: str, case_sensitive: bool = True):
if not game_title: """Выполняет поиск игры в фоновом потоке и испускает сигнал с результатами."""
return None def search_thread():
try:
results = self.search(game_name, case_sensitive)
self.searchCompleted.emit(results if results else [])
except Exception as e:
print(f"Error in search_with_callback: {e}")
self.searchCompleted.emit([])
json_response = self.http_client.search_games(game_title) thread = Thread(target=search_thread)
if not json_response: thread.daemon = True
return None thread.start()
parser = ResultParser(game_title, 0.0, False, self.auto_filter_times)
results = parser.parse_results(json_response, target_game_id=game_id)
return results[0] if results else None

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