diff --git a/misc/requirements.in b/misc/requirements.in index af17172f5f..3da70743cd 100644 --- a/misc/requirements.in +++ b/misc/requirements.in @@ -4,7 +4,7 @@ setuptools-scm requests < 3.0 PySide6-Essentials >= 6.8.1 QtAwesome -legendary-gl @ https://github.com/RareDevs/legendary/archive/refs/tags/rare-1.12.0.zip +legendary-gl @ https://github.com/RareDevs/legendary/archive/85d9ea9.zip orjson vdf @ https://github.com/solsticegamestudios/vdf/archive/be1f7220238022f8b29fe747f0b643f280bfdb6e.zip pywin32 ; platform_system == "Windows" diff --git a/pyproject.toml b/pyproject.toml index fdacfb63f6..769d282bbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "requests < 3.0", "PySide6-Essentials >= 6.8.1", "QtAwesome", - "legendary-gl @ https://github.com/RareDevs/legendary/archive/refs/tags/rare-1.12.0.zip", + "legendary-gl @ git+https://github.com/RareDevs/legendary@rare/achievements", "orjson", "vdf @ https://github.com/solsticegamestudios/vdf/archive/be1f7220238022f8b29fe747f0b643f280bfdb6e.zip", "pywin32 ; platform_system == 'Windows'", diff --git a/rare/components/tabs/integrations/ubisoft_group.py b/rare/components/tabs/integrations/ubisoft_group.py index b2041bdf97..2a3f8b587f 100644 --- a/rare/components/tabs/integrations/ubisoft_group.py +++ b/rare/components/tabs/integrations/ubisoft_group.py @@ -1,7 +1,6 @@ import time import webbrowser from logging import getLogger -from typing import Optional from legendary.models.game import Game from PySide6.QtCore import QObject, QSize, Qt, QThreadPool, Signal, Slot @@ -24,22 +23,21 @@ from rare.widgets.elide_label import ElideLabel from rare.widgets.loading_widget import LoadingWidget -logger = getLogger("Ubisoft") +class UbiGetInfoWorkerSignals(QObject): + worker_finished = Signal(set, set, str) -class UbiGetInfoWorker(Worker): - class Signals(QObject): - worker_finished = Signal(set, set, str) +class UbiGetInfoWorker(Worker): def __init__(self, core: LegendaryCore): super(UbiGetInfoWorker, self).__init__() - self.signals = UbiGetInfoWorker.Signals() + self.signals = UbiGetInfoWorkerSignals() self.setAutoDelete(True) self.core = core def run_real(self) -> None: try: - with timelogger(logger, "Request external auths"): + with timelogger(self.logger, "Request external auths"): external_auths = self.core.egs.get_external_auths() for ext_auth in external_auths: if ext_auth["type"] != "ubisoft": @@ -50,34 +48,34 @@ def run_real(self) -> None: self.signals.worker_finished.emit(set(), set(), "") return - with timelogger(logger, "Request uplay codes"): + with timelogger(self.logger, "Request uplay codes"): uplay_keys = self.core.egs.store_get_uplay_codes() key_list = uplay_keys["data"]["PartnerIntegration"]["accountUplayCodes"] redeemed = {k["gameId"] for k in key_list if k["redeemedOnUplay"]} if (entitlements := self.core.lgd.entitlements) is None: - with timelogger(logger, "Request entitlements"): + with timelogger(self.logger, "Request entitlements"): try: entitlements = self.core.egs.get_user_entitlements_full() - except AttributeError as e: - logger.warning(e) - entitlements = self.core.egs.get_user_entitlements() + except Exception as e: + self.logger.warning(e) self.core.lgd.entitlements = entitlements entitlements = {i["entitlementName"] for i in entitlements} self.signals.worker_finished.emit(redeemed, entitlements, ubi_account_id) except Exception as e: - logger.error(e) + self.logger.error(e) self.signals.worker_finished.emit(set(), set(), "error") -class UbiConnectWorker(Worker): - class Signals(QObject): - linked = Signal(str) +class UbiConnectWorkerSignals(QObject): + linked = Signal(str) + +class UbiConnectWorker(Worker): def __init__(self, core: LegendaryCore, ubi_account_id, partner_link_id): super(UbiConnectWorker, self).__init__() - self.signals = UbiConnectWorker.Signals() + self.signals = UbiConnectWorkerSignals() self.core = core self.ubi_account_id = ubi_account_id self.partner_link_id = partner_link_id @@ -160,6 +158,8 @@ def worker_finished(self, error): class UbisoftGroup(QGroupBox): def __init__(self, rcore: RareCore, parent=None): super(UbisoftGroup, self).__init__(parent=parent) + self.logger = getLogger(type(self).__name__) + self.rcore = rcore self.core = rcore.core() self.args = rcore.args() @@ -167,7 +167,7 @@ def __init__(self, rcore: RareCore, parent=None): self.setTitle(self.tr("Link Ubisoft Games")) self.thread_pool = QThreadPool.globalInstance() - self.worker: Optional[UbiGetInfoWorker] = None + self.worker: UbiGetInfoWorker = None self.info_label = QLabel(parent=self) self.info_label.setText(self.tr("Getting information about your redeemable Ubisoft games.")) @@ -192,7 +192,7 @@ def showEvent(self, a0: QShowEvent) -> None: return super().showEvent(a0) if self.worker is not None: - return + return super().showEvent(a0) for widget in self.findChildren(UbiLinkWidget, options=Qt.FindChildOption.FindDirectChildrenOnly): widget.deleteLater() @@ -201,14 +201,14 @@ def showEvent(self, a0: QShowEvent) -> None: self.worker = UbiGetInfoWorker(self.core) self.worker.signals.worker_finished.connect(self.show_ubi_games) self.thread_pool.start(self.worker) - super().showEvent(a0) + return super().showEvent(a0) @Slot(set, set, str) def show_ubi_games(self, redeemed: set, entitlements: set, ubi_account_id: str): self.worker = None self.loading_widget.stop() if not redeemed and ubi_account_id != "error": - logger.error("No linked ubisoft account found! Link your accounts via your browser and try again.") + self.logger.error("No linked ubisoft account found! Link your accounts via your browser and try again.") self.info_label.setText(self.tr("Your account is not linked with Ubisoft. Please link your account and try again.")) self.link_button.setEnabled(True) elif ubi_account_id == "error": @@ -254,7 +254,7 @@ def show_ubi_games(self, redeemed: set, entitlements: set, ubi_account_id: str): self.info_label.setText( self.tr("You have {} games available to redeem.").format(len(uplay_games) - activated) ) - logger.info(f"Found {len(uplay_games) - activated} game(s) to redeem.") + self.logger.info(f"Found {len(uplay_games) - activated} game(s) to redeem.") for game in uplay_games: widget = UbiLinkWidget( diff --git a/rare/components/tabs/library/details/details.py b/rare/components/tabs/library/details/details.py index 9d828de5a0..32568d2609 100644 --- a/rare/components/tabs/library/details/details.py +++ b/rare/components/tabs/library/details/details.py @@ -2,7 +2,7 @@ import platform from hashlib import sha1 from logging import getLogger -from typing import Optional, Tuple +from typing import Dict, Optional, Tuple from PySide6.QtCore import ( QCoreApplication, @@ -14,6 +14,9 @@ from PySide6.QtGui import QFontMetrics, QHideEvent, QShowEvent from PySide6.QtWidgets import ( QCheckBox, + QFrame, + QHBoxLayout, + QLabel, QLineEdit, QMessageBox, QSizePolicy, @@ -28,9 +31,11 @@ from rare.shared import RareCore from rare.shared.workers import MoveInfoWorker, MoveWorker, VerifyWorker from rare.ui.components.tabs.library.details.details import Ui_GameDetails -from rare.utils.misc import format_size, qta_icon, style_hyperlink +from rare.utils.misc import format_size, qta_icon, relative_date, style_hyperlink +from rare.utils.paths import cache_dir +from rare.utils.qt_requests import QtRequests from rare.widgets.dialogs import ButtonDialog, game_title -from rare.widgets.image_widget import ImageSize, ImageWidget +from rare.widgets.image_widget import ImageSize, ImageWidget, LoadingImageWidget from rare.widgets.side_tab import SideTabContents logger = getLogger("GameInfo") @@ -42,6 +47,7 @@ class GameDetails(QWidget, SideTabContents): def __init__(self, rcore: RareCore, parent=None): super(GameDetails, self).__init__(parent=parent) + self.implements_scrollarea = True self.ui = Ui_GameDetails() self.ui.setupUi(self) # lk: set object names for CSS properties @@ -67,12 +73,14 @@ def __init__(self, rcore: RareCore, parent=None): self.rcore = rcore self.core = rcore.core() self.args = rcore.args() + self.net_manager = QtRequests(cache=str(cache_dir().joinpath("achievements")), parent=self) self.rgame: Optional[RareGame] = None self.image = ImageWidget(self) self.image.setFixedSize(ImageSize.DisplayTall) self.ui.left_layout.insertWidget(0, self.image, alignment=Qt.AlignmentFlag.AlignTop) + self.ui.left_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.ui.install_button.clicked.connect(self.__on_install) self.ui.import_button.clicked.connect(self.__on_import) @@ -96,8 +104,21 @@ def __init__(self, rcore: RareCore, parent=None): self.ui.add_tag_button.setIcon(qta_icon("mdi.plus")) self.ui.add_tag_button.clicked.connect(self.__on_tag_add) + ach_progress_layout = QVBoxLayout(self.ui.ach_progress_page) + ach_progress_layout.setContentsMargins(0, 0, 0, 0) + ach_progress_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + ach_completed_layout = QVBoxLayout(self.ui.ach_completed_page) + ach_completed_layout.setContentsMargins(0, 0, 0, 0) + ach_completed_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + ach_uninitiated_layout = QVBoxLayout(self.ui.ach_uninitiated_page) + ach_uninitiated_layout.setContentsMargins(0, 0, 0, 0) + ach_uninitiated_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + ach_hidden_layout = QVBoxLayout(self.ui.ach_hidden_page) + ach_hidden_layout.setContentsMargins(0, 0, 0, 0) + ach_hidden_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + # lk: hide unfinished things - self.ui.description_label.setVisible(False) + self.ui.description_field.setVisible(False) self.ui.requirements_group.setVisible(False) @Slot() @@ -417,9 +438,71 @@ def update_game(self, rgame: RareGame): else: self.ui.install_button.setText(self.tr("Install")) + self.ui.description_field.setText(rgame.game.metadata['description']) + + for page in ( + self.ui.ach_progress_page, self.ui.ach_completed_page, self.ui.ach_uninitiated_page, self.ui.ach_hidden_page, + ): + for w in page.findChildren(AchievementWidget, options=Qt.FindChildOption.FindDirectChildrenOnly): + page.layout().removeWidget(w) + w.deleteLater() + + if ach := rgame.achievements: + self.ui.progress_field.setText(f"{ach.user_unlocked}/{ach.total_achievements}") + self.ui.exp_field.setText(f"{ach.user_xp}/{ach.total_product_xp}") + + for group, page in zip( + (ach.hidden, ach.uninitiated, ach.completed, ach.in_progress, ), + (self.ui.ach_hidden_page, self.ui.ach_uninitiated_page, self.ui.ach_completed_page, self.ui.ach_progress_page, ) + ): + self.ui.achievements_toolbox.setItemEnabled(self.ui.achievements_toolbox.indexOf(page), bool(group)) + if bool(group): + self.ui.achievements_toolbox.setCurrentWidget(page) + for item in group: + page.layout().addWidget(AchievementWidget(self.net_manager, item), alignment=Qt.AlignmentFlag.AlignTop) + else: + self.ui.progress_field.setText(self.tr("No data")) + self.ui.exp_field.setText(self.tr("No data")) + self.ui.achievements_group.setVisible(bool(ach)) + self.rgame = rgame +class AchievementWidget(QFrame): + def __init__(self, manager: QtRequests, achievement: Dict, parent=None): + super().__init__(parent=parent) + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setFrameShadow(QFrame.Shadow.Sunken) + + image = LoadingImageWidget(manager, parent=self) + image.setFixedSize(ImageSize.LibraryIcon) + image.fetchPixmap(achievement['icon_link']) + + title = QLabel( + f"{achievement['display_name']}" + f" ({achievement['xp']} XP)", + parent=self + ) + title.setWordWrap(True) + description = QLabel(achievement['description'], parent=self) + description.setWordWrap(True) + unlock_date = achievement['unlock_date'].astimezone() if achievement['unlock_date'] else None + unlock_date_str = f" ( On: {relative_date(unlock_date)} )" if unlock_date else "" + progress = QLabel(f"Progress: {achievement['progress'] * 100:,.2f}% {unlock_date_str}", parent=self) + if unlock_date: + progress.setToolTip(str(unlock_date)) + + right_layout = QVBoxLayout() + right_layout.addWidget(title, alignment=Qt.AlignmentFlag.AlignTop) + right_layout.addWidget(description, alignment=Qt.AlignmentFlag.AlignTop, stretch=1) + right_layout.addWidget(progress, alignment=Qt.AlignmentFlag.AlignBottom) + + main_layout = QHBoxLayout(self) + main_layout.setContentsMargins(3, 3, 3, 3) + main_layout.addWidget(image) + main_layout.addLayout(right_layout, stretch=1) + + class GameTagCheckBox(QCheckBox): checkStateChangedData = Signal(Qt.CheckState, str) diff --git a/rare/components/tabs/store/widgets/details.py b/rare/components/tabs/store/widgets/details.py index f98b255daa..1ad4100696 100644 --- a/rare/components/tabs/store/widgets/details.py +++ b/rare/components/tabs/store/widgets/details.py @@ -22,10 +22,9 @@ from rare.ui.components.tabs.store.details import Ui_StoreDetailsWidget from rare.utils.misc import qta_icon from rare.widgets.elide_label import ElideLabel +from rare.widgets.image_widget import LoadingSpinnerImageWidget from rare.widgets.side_tab import SideTabContents, SideTabWidget -from .image import LoadingImageWidget - logger = getLogger("StoreDetails") @@ -45,7 +44,7 @@ def __init__(self, installed: List, store_api: StoreAPI, parent=None): self.installed = installed self.catalog_offer: CatalogOfferModel = None - self.image = LoadingImageWidget(store_api.cached_manager, self) + self.image = LoadingSpinnerImageWidget(store_api.cached_manager, self) self.image.setFixedSize(ImageSize.DisplayTall) self.ui.left_layout.insertWidget(0, self.image, alignment=Qt.AlignmentFlag.AlignTop) self.ui.left_layout.setAlignment(Qt.AlignmentFlag.AlignTop) diff --git a/rare/components/tabs/store/widgets/image.py b/rare/components/tabs/store/widgets/icon_widget.py similarity index 69% rename from rare/components/tabs/store/widgets/image.py rename to rare/components/tabs/store/widgets/icon_widget.py index ab0200057a..c7905ae7dd 100644 --- a/rare/components/tabs/store/widgets/image.py +++ b/rare/components/tabs/store/widgets/icon_widget.py @@ -1,8 +1,4 @@ from PySide6.QtCore import Qt -from PySide6.QtGui import ( - QImage, - QPixmap, -) from PySide6.QtWidgets import ( QHBoxLayout, QLabel, @@ -12,10 +8,6 @@ QWidget, ) -from rare.utils.qt_requests import QtRequests -from rare.widgets.image_widget import ImageWidget -from rare.widgets.loading_widget import LoadingWidget - class IconWidget(object): def __init__(self): @@ -81,36 +73,3 @@ def setupUi(self, widget: QWidget): image_layout.addSpacerItem(QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)) image_layout.addWidget(self.mini_widget) widget.setLayout(image_layout) - - -class LoadingImageWidget(ImageWidget): - def __init__(self, manager: QtRequests, parent=None): - super(LoadingImageWidget, self).__init__(parent=parent) - self.ui = IconWidget() - self.spinner = LoadingWidget(parent=self) - self.spinner.setVisible(False) - self.manager = manager - - def fetchPixmap(self, url): - self.setPixmap(QPixmap()) - self.spinner.setFixedSize(self._image_size.size) - self.spinner.start() - self.manager.get( - url, - self.__on_image_ready, - params={ - "resize": 1, - "w": self._image_size.base.size.width(), - "h": self._image_size.base.size.height(), - }, - ) - - def __on_image_ready(self, data): - cover = QImage() - cover.loadFromData(data) - # cover = cover.scaled(self._image_size.size, Qt.KeepAspectRatio, Qt.SmoothTransformation) - cover.setDevicePixelRatio(self._image_size.base.pixel_ratio) - cover = cover.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied) - cover = QPixmap(cover) - self.setPixmap(cover) - self.spinner.stop() diff --git a/rare/components/tabs/store/widgets/items.py b/rare/components/tabs/store/widgets/items.py index 2f15030d92..3e2528df9c 100644 --- a/rare/components/tabs/store/widgets/items.py +++ b/rare/components/tabs/store/widgets/items.py @@ -8,17 +8,19 @@ from rare.models.image import ImageSize from rare.utils.misc import qta_icon from rare.utils.qt_requests import QtRequests +from rare.widgets.image_widget import LoadingSpinnerImageWidget -from .image import LoadingImageWidget +from .icon_widget import IconWidget logger = getLogger("StoreWidgets") -class ItemWidget(LoadingImageWidget): +class ItemWidgetSpinner(LoadingSpinnerImageWidget): show_details = Signal(CatalogOfferModel) def __init__(self, manager: QtRequests, catalog_game: CatalogOfferModel = None, parent=None): - super(ItemWidget, self).__init__(manager, parent=parent) + super(ItemWidgetSpinner, self).__init__(manager, parent=parent) + self.ui = IconWidget() self.catalog_game = catalog_game def mousePressEvent(self, a0: QMouseEvent) -> None: @@ -29,7 +31,7 @@ def mousePressEvent(self, a0: QMouseEvent) -> None: a0.accept() -class StoreItemWidget(ItemWidget): +class StoreItemWidget(ItemWidgetSpinner): def __init__(self, manager: QtRequests, catalog_game: CatalogOfferModel = None, parent=None): super(StoreItemWidget, self).__init__(manager, catalog_game, parent=parent) self.setFixedSize(ImageSize.DisplayWide) @@ -74,7 +76,7 @@ def init_ui(self, game: CatalogOfferModel): # logger.info(", ".join([img["type"] for img in json_info["keyImages"]])) -class SearchItemWidget(ItemWidget): +class SearchItemWidget(ItemWidgetSpinner): def __init__(self, manager: QtRequests, catalog_game: CatalogOfferModel, parent=None): super(SearchItemWidget, self).__init__(manager, catalog_game, parent=parent) self.setFixedSize(ImageSize.LibraryTall) @@ -97,7 +99,7 @@ def __init__(self, manager: QtRequests, catalog_game: CatalogOfferModel, parent= self.ui.discount_label.setVisible(False) -class WishlistItemWidget(ItemWidget): +class WishlistItemWidget(ItemWidgetSpinner): delete_from_wishlist = Signal(CatalogOfferModel) def __init__(self, manager: QtRequests, catalog_game: CatalogOfferModel, parent=None): diff --git a/rare/models/game.py b/rare/models/game.py index f2cbdf130c..9d840b68dd 100644 --- a/rare/models/game.py +++ b/rare/models/game.py @@ -1,6 +1,8 @@ import json import os import platform +import re +from argparse import Namespace from dataclasses import dataclass, field from datetime import datetime, timezone from threading import Lock @@ -30,6 +32,7 @@ class Metadata: queued: bool = False queue_pos: Optional[int] = None last_played: datetime = datetime.min.replace(tzinfo=timezone.utc) + achievements_date: datetime = datetime.min.replace(tzinfo=timezone.utc) grant_date: datetime = datetime.min.replace(tzinfo=timezone.utc) steam_appid: Optional[str] = None steam_grade: Optional[str] = None @@ -49,6 +52,7 @@ def from_dict(cls, data: Dict): queued=data.get("queued", False), queue_pos=data.get("queue_pos", None), last_played=RareGame.Metadata.parse_date(data.get("last_played", "")), + achievements_date=RareGame.Metadata.parse_date(data.get("achievements_date", "")), grant_date=RareGame.Metadata.parse_date(data.get("grant_date", "")), steam_appid=str(appid) if (appid := data.get("steam_appid", "")) else None, steam_grade=data.get("steam_grade", None), @@ -63,6 +67,7 @@ def __dict__(self): queued=self.queued, queue_pos=self.queue_pos, last_played=self.last_played.isoformat() if self.last_played else datetime.min.replace(tzinfo=timezone.utc), + achievements_date=self.last_played.isoformat() if self.achievements_date else datetime.min.replace(tzinfo=timezone.utc), grant_date=self.grant_date.isoformat() if self.grant_date else datetime.min.replace(tzinfo=timezone.utc), steam_appid=str(self.steam_appid) if self.steam_appid else None, steam_grade=self.steam_grade, @@ -464,6 +469,50 @@ def save_path(self, path: str) -> None: self.store_igame() self.signals.widget.update.emit() + @property + def achievements(self) -> Optional[Namespace]: + if not self.game.achievements or not self.game.achievements.achievements: + self.logger.info("No achievements found for %s (%s)", self.app_name, self.app_title) + return None + + # if self.game.achievements is None: + # _ = self.core.get_game(self.app_name, update_meta=True, platform=self.default_platform) + # try: + # ret = self.core.get_achievements(self.game, update=True) + # except Exception as e: + # ret = self.core.get_achievements(self.game, update=False) + + update = not self.core.lgd.achievements or not self.core.lgd.achievements.get(self.app_name, None) + update = update or self.metadata.achievements_date < self.metadata.last_played + if update: + self.logger.info("Updating achievements for %s (%s)", self.app_name, self.app_title) + ret = self.core.get_achievements(self.game, update=update) + self.metadata.achievements_date = self.metadata.last_played + self.__save_metadata() + if ret is not None: + ret = Namespace(**ret) + return ret + + @property + def eulas(self) -> List: + eulas = self.game.metadata.get('eulaIds') or ['$'] + + pattern = r'\w+' + keys = [] + for eula in eulas: + keys += re.findall(pattern, eula) + + not_accepted_eulas = [] + for key in keys: + # if args.skip_epic and key == 'egstore': + # continue + self.logger.debug(f'Fetching eula status for "{key}"') + eula = self.core.egs.eula_get_status(key) + if eula: + not_accepted_eulas.append(eula) + + return not_accepted_eulas + def reset_steam_date(self): self.metadata.steam_date = datetime.min.replace(tzinfo=timezone.utc) self.signals.widget.update.emit() @@ -515,7 +564,7 @@ def grant_date(self, force=False) -> datetime: grant_date = ( datetime.fromisoformat(entitlement["grantDate"].replace("Z", "+00:00")) if entitlement - else datetime.min.replace(tzinfo=timezone.utc) + else datetime.fromisoformat(self.game.metadata["creationDate"].replace("Z", "+00:00")) ) self.metadata.grant_date = grant_date self.__save_metadata() diff --git a/rare/shared/workers/fetch.py b/rare/shared/workers/fetch.py index 4393cabad0..4c2dad2adc 100644 --- a/rare/shared/workers/fetch.py +++ b/rare/shared/workers/fetch.py @@ -13,6 +13,11 @@ from .worker import Worker +class FetchWorkerSignals(QObject): + progress = Signal(int, str) + result = Signal(object, int) + + class FetchWorker(Worker): class Result(IntEnum): ERROR = 0 @@ -20,13 +25,9 @@ class Result(IntEnum): ENTITLEMENTS = 2 STEAMAPPIDS = 3 - class Signals(QObject): - progress = Signal(int, str) - result = Signal(object, int) - def __init__(self, settings: RareAppSettings, core: LegendaryCore, args: Namespace): super(FetchWorker, self).__init__() - self.signals = FetchWorker.Signals() + self.signals = FetchWorkerSignals() self.settings = settings self.core = core self.args = args @@ -56,9 +57,8 @@ def run_real(self): with timelogger(self.logger, "Request entitlements"): try: entitlements = self.core.egs.get_user_entitlements_full() - except AttributeError as e: + except Exception as e: self.logger.warning(e) - entitlements = self.core.egs.get_user_entitlements() self.core.lgd.entitlements = entitlements self.logger.info("Entitlements: %s", len(list(entitlements))) self.signals.result.emit(entitlements, FetchWorker.Result.ENTITLEMENTS) diff --git a/rare/ui/components/tabs/library/details/details.py b/rare/ui/components/tabs/library/details/details.py index d4cda99ada..c7a74d1414 100644 --- a/rare/ui/components/tabs/library/details/details.py +++ b/rare/ui/components/tabs/library/details/details.py @@ -3,12 +3,12 @@ ################################################################################ ## Form generated from reading UI file 'details.ui' ## -## Created by: Qt User Interface Compiler version 6.9.1 +## Created by: Qt User Interface Compiler version 6.10.1 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ -from PySide6.QtCore import QCoreApplication, QSize, Qt +from PySide6.QtCore import QCoreApplication, QRect, QSize, Qt from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QFormLayout, @@ -24,6 +24,7 @@ QSpacerItem, QStackedWidget, QTextBrowser, + QToolBox, QVBoxLayout, QWidget, ) @@ -33,7 +34,7 @@ class Ui_GameDetails(object): def setupUi(self, GameDetails): if not GameDetails.objectName(): GameDetails.setObjectName(u"GameDetails") - GameDetails.resize(795, 404) + GameDetails.resize(888, 603) GameDetails.setWindowTitle(u"GameDetails") self.main_layout = QHBoxLayout(GameDetails) self.main_layout.setObjectName(u"main_layout") @@ -324,11 +325,11 @@ def setupUi(self, GameDetails): self.right_layout.addLayout(self.details_layout, 0, 0, 1, 1) - self.description_label = QTextBrowser(GameDetails) - self.description_label.setObjectName(u"description_label") - self.description_label.setOpenExternalLinks(True) + self.description_field = QTextBrowser(GameDetails) + self.description_field.setObjectName(u"description_field") + self.description_field.setOpenExternalLinks(True) - self.right_layout.addWidget(self.description_label, 0, 1, 1, 1) + self.right_layout.addWidget(self.description_field, 1, 0, 1, 1) self.requirements_group = QFrame(GameDetails) self.requirements_group.setObjectName(u"requirements_group") @@ -336,9 +337,65 @@ def setupUi(self, GameDetails): self.requirements_group.setFrameShadow(QFrame.Shadow.Sunken) self.requirements_layout = QHBoxLayout(self.requirements_group) self.requirements_layout.setObjectName(u"requirements_layout") - self.requirements_layout.setContentsMargins(0, 0, 0, 0) - self.right_layout.addWidget(self.requirements_group, 1, 0, 1, 2, Qt.AlignmentFlag.AlignBottom) + self.right_layout.addWidget(self.requirements_group, 2, 0, 2, 2) + + self.achievements_group = QWidget(GameDetails) + self.achievements_group.setObjectName(u"achievements_group") + self.achievements_layout = QVBoxLayout(self.achievements_group) + self.achievements_layout.setObjectName(u"achievements_layout") + self.achievements_layout.setContentsMargins(0, 0, 0, 0) + self.achievement_stats_layout = QHBoxLayout() + self.achievement_stats_layout.setObjectName(u"achievement_stats_layout") + self.achievement_stats_layout.setContentsMargins(0, -1, 0, -1) + self.progress_label = QLabel(self.achievements_group) + self.progress_label.setObjectName(u"progress_label") + self.progress_label.setFont(font) + + self.achievement_stats_layout.addWidget(self.progress_label) + + self.progress_field = QLabel(self.achievements_group) + self.progress_field.setObjectName(u"progress_field") + + self.achievement_stats_layout.addWidget(self.progress_field) + + self.exp_label = QLabel(self.achievements_group) + self.exp_label.setObjectName(u"exp_label") + self.exp_label.setFont(font) + + self.achievement_stats_layout.addWidget(self.exp_label) + + self.exp_field = QLabel(self.achievements_group) + self.exp_field.setObjectName(u"exp_field") + + self.achievement_stats_layout.addWidget(self.exp_field) + + self.achievement_stats_layout.setStretch(1, 1) + self.achievement_stats_layout.setStretch(3, 1) + + self.achievements_layout.addLayout(self.achievement_stats_layout) + + self.achievements_toolbox = QToolBox(self.achievements_group) + self.achievements_toolbox.setObjectName(u"achievements_toolbox") + self.ach_progress_page = QWidget() + self.ach_progress_page.setObjectName(u"ach_progress_page") + self.ach_progress_page.setGeometry(QRect(0, 0, 320, 419)) + self.achievements_toolbox.addItem(self.ach_progress_page, u"In progress") + self.ach_completed_page = QWidget() + self.ach_completed_page.setObjectName(u"ach_completed_page") + self.ach_completed_page.setGeometry(QRect(0, 0, 320, 419)) + self.achievements_toolbox.addItem(self.ach_completed_page, u"Completed") + self.ach_uninitiated_page = QWidget() + self.ach_uninitiated_page.setObjectName(u"ach_uninitiated_page") + self.achievements_toolbox.addItem(self.ach_uninitiated_page, u"Uninitiated") + self.ach_hidden_page = QWidget() + self.ach_hidden_page.setObjectName(u"ach_hidden_page") + self.achievements_toolbox.addItem(self.ach_hidden_page, u"Hidden") + + self.achievements_layout.addWidget(self.achievements_toolbox) + + + self.right_layout.addWidget(self.achievements_group, 0, 1, 2, 1) self.right_layout.setRowStretch(1, 1) self.right_layout.setColumnStretch(1, 1) @@ -352,6 +409,7 @@ def setupUi(self, GameDetails): self.actions_stack.setCurrentIndex(0) self.verify_stack.setCurrentIndex(0) self.move_stack.setCurrentIndex(0) + self.achievements_toolbox.setCurrentIndex(0) # setupUi @@ -376,6 +434,14 @@ def retranslateUi(self, GameDetails): self.uninstall_button.setText(QCoreApplication.translate("GameDetails", u"Uninstall", None)) self.install_button.setText(QCoreApplication.translate("GameDetails", u"Install", None)) self.import_button.setText(QCoreApplication.translate("GameDetails", u"Import", None)) + self.progress_label.setText(QCoreApplication.translate("GameDetails", u"Progress:", None)) + self.progress_field.setText(QCoreApplication.translate("GameDetails", u"progress_error", None)) + self.exp_label.setText(QCoreApplication.translate("GameDetails", u"Experience:", None)) + self.exp_field.setText(QCoreApplication.translate("GameDetails", u"xp_error", None)) + self.achievements_toolbox.setItemText(self.achievements_toolbox.indexOf(self.ach_progress_page), QCoreApplication.translate("GameDetails", u"In progress", None)) + self.achievements_toolbox.setItemText(self.achievements_toolbox.indexOf(self.ach_completed_page), QCoreApplication.translate("GameDetails", u"Completed", None)) + self.achievements_toolbox.setItemText(self.achievements_toolbox.indexOf(self.ach_uninitiated_page), QCoreApplication.translate("GameDetails", u"Uninitiated", None)) + self.achievements_toolbox.setItemText(self.achievements_toolbox.indexOf(self.ach_hidden_page), QCoreApplication.translate("GameDetails", u"Hidden", None)) pass # retranslateUi diff --git a/rare/ui/components/tabs/library/details/details.ui b/rare/ui/components/tabs/library/details/details.ui index 7c0000688b..c3369b32f2 100644 --- a/rare/ui/components/tabs/library/details/details.ui +++ b/rare/ui/components/tabs/library/details/details.ui @@ -6,8 +6,8 @@ 0 0 - 795 - 404 + 842 + 603 @@ -53,7 +53,7 @@ - + 24 @@ -546,14 +546,14 @@ - - + + true - + QFrame::Shape::StyledPanel @@ -561,7 +561,12 @@ QFrame::Shadow::Sunken - + + + + + + 0 @@ -574,6 +579,113 @@ 0 + + + + 0 + + + 0 + + + + + + true + + + + Progress: + + + + + + + progress_error + + + + + + + + true + + + + Experience: + + + + + + + xp_error + + + + + + + + + 0 + + + + + 0 + 0 + 274 + 419 + + + + In progress + + + + + + 0 + 0 + 274 + 419 + + + + Completed + + + + + + 0 + 0 + 274 + 419 + + + + Uninitiated + + + + + + 0 + 0 + 274 + 419 + + + + Hidden + + + + diff --git a/rare/utils/misc.py b/rare/utils/misc.py index e32a8de3bc..e81cb4bda4 100644 --- a/rare/utils/misc.py +++ b/rare/utils/misc.py @@ -1,4 +1,5 @@ import os +from datetime import UTC, datetime from enum import IntEnum from logging import getLogger from typing import Dict, Iterable, Tuple, Type, Union @@ -171,6 +172,30 @@ def format_size(b: Union[int, float]) -> str: if b < 1024: return f"{b:.2f} {s}B" b /= 1024 + return str(b) + + +def relative_date(date): + diff = datetime.now(UTC) - date + s = diff.seconds + if diff.days > 7 or diff.days < 0: + return date.strftime('%d %b %y') + elif diff.days == 1: + return '1 day ago' + elif diff.days > 1: + return '{} days ago'.format(diff.days) + elif s <= 1: + return 'just now' + elif s < 60: + return '{} seconds ago'.format(s) + elif s < 120: + return '1 minute ago' + elif s < 3600: + return '{} minutes ago'.format(s//60) + elif s < 7200: + return '1 hour ago' + else: + return '{} hours ago'.format(s//3600) def qta_icon(icn_str: str, fallback: str = None, **kwargs): diff --git a/rare/utils/qt_requests.py b/rare/utils/qt_requests.py index d06c1f87fe..a9a0431592 100644 --- a/rare/utils/qt_requests.py +++ b/rare/utils/qt_requests.py @@ -34,18 +34,18 @@ class QtRequests(QObject): def __init__(self, cache: str = None, token: str = None, parent=None): super(QtRequests, self).__init__(parent=parent) - self.log = getLogger(f"{type(self).__name__}_{type(parent).__name__}") - self.manager = QNetworkAccessManager(self) - self.manager.finished.connect(self.__on_finished) - self.cache = None + self.logger = getLogger(f"{type(self).__name__}_{type(parent).__name__}") + self._manager = QNetworkAccessManager(self) + self._manager.finished.connect(self.__on_finished) + self._cache = None if cache is not None: - self.log.debug("Using cache dir %s", cache) - self.cache = QNetworkDiskCache(self) - self.cache.setCacheDirectory(cache) - self.manager.setCache(self.cache) + self.logger.debug("Using cache dir %s", cache) + self._cache = QNetworkDiskCache(self) + self._cache.setCacheDirectory(cache) + self._manager.setCache(self._cache) if token is not None: - self.log.debug("Manager is authorized") - self.token = token + self.logger.debug("Manager is authorized") + self._token = token self.__active_requests: Dict[QNetworkReply, RequestQueueItem] = {} @@ -69,19 +69,19 @@ def __prepare_request(self, item: RequestQueueItem) -> QNetworkRequest: QNetworkRequest.Attribute.RedirectPolicyAttribute, QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy, ) - if self.cache is not None: + if self._cache is not None: request.setAttribute( QNetworkRequest.Attribute.CacheLoadControlAttribute, QNetworkRequest.CacheLoadControl.PreferCache, ) - if self.token is not None: - request.setRawHeader(b"Authorization", self.token.encode()) + if self._token is not None: + request.setRawHeader(b"Authorization", self._token.encode()) return request def __post(self, item: RequestQueueItem): request = self.__prepare_request(item) payload = orjson.dumps(item.payload) - reply = self.manager.post(request, payload) + reply = self._manager.post(request, payload) reply.errorOccurred.connect(self.__on_error) self.__active_requests[reply] = item @@ -91,7 +91,7 @@ def post(self, url: str, handler: RequestHandler, payload: dict): def __get(self, item: RequestQueueItem): request = self.__prepare_request(item) - reply = self.manager.get(request) + reply = self._manager.get(request) reply.errorOccurred.connect(self.__on_error) self.__active_requests[reply] = item @@ -107,7 +107,7 @@ def get( self.__get(item) def __on_error(self, error: QNetworkReply.NetworkError) -> None: - self.log.error(error) + self.logger.error(error) @staticmethod def __parse_content_type(header) -> Tuple[str, str]: @@ -120,11 +120,11 @@ def __parse_content_type(header) -> Tuple[str, str]: def __on_finished(self, reply: QNetworkReply): item = self.__active_requests.pop(reply, None) if item is None: - self.log.error("QNetworkReply: %s without associated item", reply.url().toString()) + self.logger.error("QNetworkReply: %s without associated item", reply.url().toString()) reply.deleteLater() return if reply.error() != QNetworkReply.NetworkError.NoError: - self.log.error(reply.errorString()) + self.logger.error(reply.errorString()) else: mimetype, charset = self.__parse_content_type(reply.header(QNetworkRequest.KnownHeaders.ContentTypeHeader)) maintype, subtype = mimetype.split("/") diff --git a/rare/widgets/image_widget.py b/rare/widgets/image_widget.py index c1ad3eb58e..2ad859060e 100644 --- a/rare/widgets/image_widget.py +++ b/rare/widgets/image_widget.py @@ -1,10 +1,11 @@ from enum import Enum -from typing import Optional, Tuple, Union +from typing import Dict, Optional, Tuple, Union from PySide6.QtCore import QRectF, QSize, Qt from PySide6.QtGui import ( QBrush, QColor, + QImage, QLinearGradient, QPainter, QPainterPath, @@ -16,6 +17,9 @@ from PySide6.QtWidgets import QWidget from rare.models.image import ImageSize +from rare.utils.qt_requests import QtRequests + +from .loading_widget import LoadingWidget OverlayPath = Tuple[QPainterPath, Union[QColor, QLinearGradient]] @@ -154,4 +158,47 @@ def paintEvent(self, a0: QPaintEvent) -> None: painter.end() -__all__ = ["ImageSize", "ImageWidget"] +class LoadingImageWidget(ImageWidget): + def __init__(self, manager: QtRequests, parent=None): + super(LoadingImageWidget, self).__init__(parent=parent) + self.manager = manager + + def fetchPixmap(self, url: str, params: Dict = None): + self.setPixmap(QPixmap()) + self.manager.get(url, self._on_image_ready, params=params) + + def _on_image_ready(self, data): + cover = QImage() + cover.loadFromData(data) + cover.setDevicePixelRatio(self._image_size.base.pixel_ratio) + if cover.size() != self._image_size.base.size: + cover = cover.scaled( + self._image_size.base.size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation + ) + cover = cover.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied) + cover = QPixmap(cover) + self.setPixmap(cover) + + +class LoadingSpinnerImageWidget(LoadingImageWidget): + def __init__(self, manager: QtRequests, parent=None): + super(LoadingSpinnerImageWidget, self).__init__(manager, parent=parent) + self.spinner = LoadingWidget(parent=self) + self.spinner.setVisible(False) + + def fetchPixmap(self, url: str, params: Dict = None): + self.spinner.start() + self.spinner.setFixedSize(self._image_size.size) + params = { + "resize": 1, + "w": self._image_size.base.size.width(), + "h": self._image_size.base.size.height(), + } if not params else params + super().fetchPixmap(url, params=params) + + def _on_image_ready(self, data): + super()._on_image_ready(data) + self.spinner.stop() + + +__all__ = ["ImageSize", "ImageWidget", "LoadingImageWidget", "LoadingSpinnerImageWidget"]