"""Game Info Panel implementation""" import os import shutil import uuid # Standard Library # pylint: disable=no-member,too-many-public-methods from gettext import gettext as _ from pathlib import Path from typing import Any, Optional import requests # Third Party Libraries from gi.repository import GdkPixbuf, Gtk, Pango # Lutris Modules from lutris import settings from lutris.game import Game from lutris.gui.config.boxes import AdvancedSettingsBox from lutris.gui.widgets.common import Label, NumberEntry, SlugEntry from lutris.gui.widgets.scaled_image import ScaledImage from lutris.gui.widgets.utils import MEDIA_CACHE_INVALIDATED, get_image_file_extension, open_uri from lutris.runners import get_installed from lutris.services.lutris import LutrisBanner, LutrisCoverart, LutrisIcon, download_lutris_media from lutris.services.service_media import resolve_media_path from lutris.util.jobs import AsyncCall from lutris.util.log import logger COVERART_KEY = "coverart_big" BANNER_KEY = "banner" ICON_KEY = "icon" class GameInfoBox(AdvancedSettingsBox): """Generate a vbox for the Game Info tab.""" def __init__(self, parent_widget: Any, game: Optional[Game], **kwargs) -> None: super().__init__(**kwargs) self.parent_widget = parent_widget self.game = game self.slug = game.slug if game else None self.initial_slug = game.slug if game else None self.name_entry = None self.sortname_entry = None self.runner_dropdown = None self.runner_index = None self.slug_entry = None self.slug_change_button = None self.directory_entry = None self.year_entry = None self.playtime_entry = None self.service_medias = {ICON_KEY: LutrisIcon(), BANNER_KEY: LutrisBanner(), COVERART_KEY: LutrisCoverart()} self.image_buttons = {} self.image_path_entries = {} self.image_path_open_button = {} self._game_config_location_entry = None if self.game: centering_container = Gtk.HBox() banner_box = self._get_banner_box() centering_container.pack_start(banner_box, True, False, 0) self.pack_start(centering_container, False, False, 0) # Banner self._get_banner_entries() # Cover Browse, Banner Browse, Icon Browse self.pack_start(self._get_name_box(), False, False, 6) # Game name self.pack_start(self._get_sortname_box(), False, False, 6) # Game sort name self.pack_start(self._get_runner_box(), False, False, 6) # Runner self.pack_start(self._get_year_box(), False, False, 6) # Year self.pack_start(self._get_playtime_box(), False, False, 6) # Playtime if self.game: self.pack_start(self._get_slug_box(), False, False, 6) self.pack_start(self._get_directory_box(), False, False, 6) self.pack_start(self._get_launch_config_box(), False, False, 6) self._game_config_location_entry = self._get_game_config_location_box() self.pack_start(self._game_config_location_entry, False, False, 6) # Read the show advanced options from the settings before updating the widget to have # it show correctly self.advanced_visibility = settings.read_setting("show_advanced_options") == "True" self.update_widgets() def update_widgets(self): if self.game: self._cover_entry.set_visible(self._advanced_visibility) self._cover_entry.set_no_show_all(not self._advanced_visibility) self._banner_entry.set_visible(self._advanced_visibility) self._banner_entry.set_no_show_all(not self._advanced_visibility) self._icon_entry.set_visible(self._advanced_visibility) self._icon_entry.set_no_show_all(not self._advanced_visibility) if self._game_config_location_entry: self._game_config_location_entry.set_visible(self._advanced_visibility) self._game_config_location_entry.set_no_show_all(not self._advanced_visibility) def _get_name_box(self): box = Gtk.Box(spacing=12, margin_right=12, margin_left=12) label = Label(_("Name")) box.pack_start(label, False, False, 0) self.name_entry = Gtk.Entry() self.name_entry.set_max_length(150) if self.game: self.name_entry.set_text(self.game.name) box.pack_start(self.name_entry, True, True, 0) return box def _get_sortname_box(self): box = Gtk.Box(spacing=12, margin_right=12, margin_left=12) label = Label(_("Sort name")) box.pack_start(label, False, False, 0) self.sortname_entry = Gtk.Entry() self.sortname_entry.set_max_length(150) if self.game: self.sortname_entry.set_placeholder_text(self.game.name) if self.game.sortname: self.sortname_entry.set_text(self.game.sortname) box.pack_start(self.sortname_entry, True, True, 0) return box def _get_year_box(self): box = Gtk.Box(spacing=12, margin_right=12, margin_left=12) label = Label(_("Release year")) box.pack_start(label, False, False, 0) self.year_entry = NumberEntry() self.year_entry.set_max_length(10) if self.game: self.year_entry.set_text(str(self.game.year or "")) box.pack_start(self.year_entry, True, True, 0) return box def _get_playtime_box(self): box = Gtk.Box(spacing=12, margin_right=12, margin_left=12) label = Label(_("Playtime")) box.pack_start(label, False, False, 0) self.playtime_entry = Gtk.Entry() if self.game: self.playtime_entry.set_text(self.game.formatted_playtime) box.pack_start(self.playtime_entry, True, True, 0) return box def _get_slug_box(self): slug_box = Gtk.VBox(spacing=12, margin_right=12, margin_left=12) slug_entry_box = Gtk.Box(spacing=12, margin_right=0, margin_left=0) slug_label = Label() slug_label.set_markup( _(f"""Identifier\n(Internal ID: {self.game.id if self.game else '""'})""") ) slug_entry_box.pack_start(slug_label, False, False, 0) self.slug_entry = SlugEntry() if self.game: self.slug_entry.set_text(self.game.slug) self.slug_entry.set_sensitive(False) self.slug_entry.connect("activate", self.on_slug_entry_activate) slug_entry_box.pack_start(self.slug_entry, True, True, 0) self.slug_change_button = Gtk.Button(_("Change")) self.slug_change_button.connect("clicked", self.on_slug_change_clicked) slug_entry_box.pack_start(self.slug_change_button, False, False, 0) slug_box.pack_start(slug_entry_box, True, True, 0) return slug_box def _get_directory_box(self): """Return widget displaying the location of the game and allowing to move it""" box = Gtk.Box(spacing=12, margin_right=12, margin_left=12, visible=True) label = Label(_("Directory")) box.pack_start(label, False, False, 0) self.directory_entry = Gtk.Entry(visible=True) if self.game: self.directory_entry.set_text(self.game.directory) self.directory_entry.set_sensitive(False) box.pack_start(self.directory_entry, True, True, 0) move_button = Gtk.Button(_("Move"), visible=True) move_button.connect("clicked", self.parent_widget.on_move_clicked) box.pack_start(move_button, False, False, 0) return box def _get_launch_config_box(self): box = Gtk.Box(spacing=12, margin_right=12, margin_left=12, visible=True) if self.game and self.game.config: game_config = self.game.config.game_level.get("game", {}) else: game_config = {} preferred_name = game_config.get("preferred_launch_config_name") if preferred_name: spacer = Gtk.Box() spacer.set_size_request(230, -1) box.pack_start(spacer, False, False, 0) if preferred_name == Game.PRIMARY_LAUNCH_CONFIG_NAME: text = _("The default launch option will be used for this game") else: text = _("The '%s' launch option will be used for this game") % preferred_name label = Gtk.Label(text) label.set_line_wrap(True) label.set_halign(Gtk.Align.START) label.set_xalign(0.0) label.set_valign(Gtk.Align.CENTER) box.pack_start(label, True, True, 0) button = Gtk.Button(_("Reset")) button.connect("clicked", self.on_reset_preferred_launch_config_clicked, box) button.set_valign(Gtk.Align.CENTER) box.pack_start(button, False, False, 0) else: box.hide() return box def _get_game_config_location_box(self): box = Gtk.Box(spacing=12, margin_right=12, margin_left=12, visible=True) game_config_path = "" if self.game and self.game.config and isinstance(self.game.config.game_config_path, str): game_config_path = self.game.config.game_config_path label = Label(_("Game Config")) box.pack_start(label, False, False, 0) # Get the current entry path for the image type and set it as the text path_entry = Gtk.Entry(visible=True) path_entry.set_text(game_config_path) path_entry.set_tooltip_text(_("Path to the game config file (readonly)")) path_entry.set_sensitive(False) box.pack_start(path_entry, True, True, 0) open_dir_button = Gtk.Button.new_from_icon_name("folder-symbolic", Gtk.IconSize.BUTTON) open_dir_button.show() open_dir_button.set_tooltip_text(_("Open in file browser")) open_dir_button.get_style_context().add_class("circular") def open_in_file_browser(_widget, game_info_box): if ( game_info_box.game and game_info_box.game.config and isinstance(game_info_box.game.config.game_config_path, str) ): game_config_dir = Path(game_info_box.game.config.game_config_path).parent open_uri(str(game_config_dir)) open_dir_button.connect("clicked", open_in_file_browser, self) box.pack_start(open_dir_button, False, False, 0) return box def on_reset_preferred_launch_config_clicked(self, _button, launch_config_box): game_config = self.game.config.game_level.get("game", {}) if self.game and self.game.config else {} game_config.pop("preferred_launch_config_name", None) game_config.pop("preferred_launch_config_index", None) launch_config_box.hide() def _get_runner_box(self): runner_box = Gtk.Box(spacing=12, margin_right=12, margin_left=12) runner_label = Label(_("Runner")) runner_box.pack_start(runner_label, False, False, 0) self.runner_dropdown = self._get_runner_dropdown() runner_box.pack_start(self.runner_dropdown, True, True, 0) return runner_box def _get_banner_box(self): banner_box = Gtk.Grid() banner_box.set_margin_top(12) banner_box.set_column_spacing(12) banner_box.set_row_spacing(4) self._create_image_button( banner_box, COVERART_KEY, _("Set custom cover art"), _("Remove custom cover art"), _("Download custom cover art"), ) self._create_image_button( banner_box, BANNER_KEY, _("Set custom banner"), _("Remove custom banner"), _("Download custom banner") ) self._create_image_button( banner_box, ICON_KEY, _("Set custom icon"), _("Remove custom icon"), _("Download custom icon") ) return banner_box def _get_banner_entries(self): self._cover_entry = self._create_image_entry(COVERART_KEY, _("Cover"), _("Location of custom cover art")) self.pack_start(self._cover_entry, False, False, 6) # Cover self._banner_entry = self._create_image_entry(BANNER_KEY, _("Banner"), _("Location of custom banner")) self.pack_start(self._banner_entry, False, False, 6) # Banner self._icon_entry = self._create_image_entry(ICON_KEY, _("Icon"), _("Location of custom icon")) self.pack_start(self._icon_entry, False, False, 6) # Icon def _create_image_entry(self, image_type, image_label, image_entry_tooltip): """Return widget displaying the location of the coverart, banner or icon image""" box = Gtk.Box(spacing=12, margin_right=12, margin_left=12, visible=True) label = Label(image_label) box.pack_start(label, False, False, 0) # Get the current entry path for the image type and set it as the text path_entry = Gtk.Entry(visible=True) path_entry.set_tooltip_text(image_entry_tooltip) path_entry.set_sensitive(False) box.pack_start(path_entry, True, True, 0) open_button = Gtk.Button.new_from_icon_name("folder-symbolic", Gtk.IconSize.BUTTON) open_button.show() open_button.set_tooltip_text(_("Open in file browser")) open_button.get_style_context().add_class("circular") open_button.connect("clicked", self.on_open_image_location_in_file_browser_clicked, image_type) self.image_path_open_button[image_type] = open_button path_entry.connect("changed", lambda _: self.refresh_image_path_entry(image_type)) box.pack_start(open_button, False, False, 0) self.image_path_entries[image_type] = path_entry # Refresh the path entry in the image text entry after it has been added to the image_path_entries dict self.refresh_image_path_entry(image_type) return box def _create_image_button(self, banner_box, image_type, image_tooltip, reset_tooltip, download_tooltip): """This adds an image button and its reset button to the box given, and adds the image button to self.image_buttons for future reference.""" image_button_container = Gtk.VBox() button_container = Gtk.HBox() image_button = Gtk.Button() self._set_image(image_type, image_button) image_button.set_valign(Gtk.Align.CENTER) image_button.set_tooltip_text(image_tooltip) image_button.connect("clicked", self.on_custom_image_select, image_type) image_button_container.pack_start(image_button, True, True, 0) reset_button = Gtk.Button.new_from_icon_name("edit-undo-symbolic", Gtk.IconSize.MENU) reset_button.set_relief(Gtk.ReliefStyle.NONE) reset_button.set_tooltip_text(reset_tooltip) reset_button.connect("clicked", self.on_custom_image_reset_clicked, image_type) reset_button.set_valign(Gtk.Align.CENTER) button_container.pack_start(reset_button, True, False, 0) download_button = Gtk.Button.new_from_icon_name("web-browser-symbolic", Gtk.IconSize.MENU) download_button.set_relief(Gtk.ReliefStyle.NONE) download_button.set_tooltip_text(download_tooltip) download_button.connect("clicked", self.on_custom_image_download_clicked, image_type) download_button.set_valign(Gtk.Align.CENTER) button_container.pack_end(download_button, True, False, 0) banner_box.add(image_button_container) banner_box.attach_next_to(button_container, image_button_container, Gtk.PositionType.BOTTOM, 1, 1) self.image_buttons[image_type] = image_button def _set_image(self, image_format, image_button): scale_factor = self.get_scale_factor() service_media = self.service_medias[image_format] game_slug = self.slug or (self.game.slug if self.game else "") media_path = resolve_media_path(service_media.get_possible_media_paths(game_slug)).path try: image = ScaledImage.new_from_media_path(media_path, service_media.config_ui_size, scale_factor) image_button.set_image(image) except Exception as ex: # We need to survive nasty data in the media files, so the user can replace # them. logger.exception("Unable to load media '%s': %s", image_format, ex) def _get_runner_dropdown(self): runner_liststore = self._get_runner_liststore() runner_dropdown = Gtk.ComboBox.new_with_model(runner_liststore) runner_dropdown.set_id_column(1) runner_index = 0 if self.parent_widget.runner_name: for runner in runner_liststore: if self.parent_widget.runner_name == str(runner[1]): break runner_index += 1 self.runner_index = runner_index runner_dropdown.set_active(self.runner_index) runner_dropdown.connect("changed", self.parent_widget.on_runner_changed) cell = Gtk.CellRendererText() cell.props.ellipsize = Pango.EllipsizeMode.END runner_dropdown.pack_start(cell, True) runner_dropdown.add_attribute(cell, "text", 0) return runner_dropdown @staticmethod def _get_runner_liststore(): """Build a ListStore with available runners.""" runner_liststore = Gtk.ListStore(str, str) runner_liststore.append((_("Select a runner from the list"), "")) for runner in get_installed(): description = runner.description runner_liststore.append(("%s (%s)" % (runner.human_name, description), runner.name)) return runner_liststore def on_slug_change_clicked(self, widget): if not self.slug_entry: return if self.slug_entry.get_sensitive() is False: widget.set_label(_("Apply")) self.slug_entry.set_sensitive(True) else: self.change_game_slug() def on_slug_entry_activate(self, _widget): self.change_game_slug() def change_game_slug(self): if not self.slug_entry: return slug = self.slug_entry.get_text() self.slug = slug self.slug_entry.set_sensitive(False) if self.slug_change_button: self.slug_change_button.set_label(_("Change")) AsyncCall(download_lutris_media, self.refresh_all_images_cb, self.slug) def refresh_all_images_cb(self, _result, _error): for image_type, image_button in self.image_buttons.items(): self._set_image(image_type, image_button) def get_image_path(self, image_type) -> Optional[Path]: """Get the path of the image file""" if image_type not in self.service_medias: return None service_media = self.service_medias[image_type] game_slug = self.slug or (self.game.slug if self.game else "") media_path = resolve_media_path(service_media.get_possible_media_paths(game_slug)) image_path = None if media_path.exists: image_path = Path(media_path.path) return image_path def on_open_image_location_in_file_browser_clicked(self, _widget, image_type): if path := self.get_image_path(image_type): open_uri(str(path.parent)) def refresh_image_path_entry(self, image_type): image_path = str(self.get_image_path(image_type) or "") if image_path_entry := self.image_path_entries.get(image_type): image_path_entry.set_text(image_path) self.image_path_open_button[image_type].set_sensitive(bool(image_path)) def on_custom_image_select(self, _widget, image_type): dialog = Gtk.FileChooserNative.new( _("Please choose a custom image"), self.parent_widget, Gtk.FileChooserAction.OPEN, None, None, ) image_filter = Gtk.FileFilter() image_filter.set_name(_("Images")) image_filter.add_pixbuf_formats() dialog.add_filter(image_filter) response = dialog.run() if response == Gtk.ResponseType.ACCEPT: image_path = dialog.get_filename() self.save_custom_media(image_type, image_path) dialog.destroy() def on_custom_image_reset_clicked(self, _widget, image_type): self.refresh_image(image_type) def on_custom_image_download_clicked(self, _widget, image_type): dialog = UrlDialog(self.parent_widget) response = dialog.run() if response != Gtk.ResponseType.OK: dialog.destroy() return url = dialog.get_url() dialog.destroy() file_id = uuid.uuid4() tmp_file = os.path.join(settings.TMP_DIR, f"download-{file_id}.tmp") logger.info(f"Downloading custom image from `{url}` to `{tmp_file}`") def download(): with requests.get(url, stream=True) as r: if not r.ok: logger.error( f"Request returned a status code that didn't indicate success: `{url}` (`{r.status_code}`)" ) return with open(tmp_file, "wb") as fp: for chunk in r.iter_content(chunk_size=8196): if chunk: fp.write(chunk) self.save_custom_media(image_type, tmp_file) def download_cb(_result, error): if error: raise error AsyncCall(download, download_cb) def save_custom_media(self, image_type: str, image_path: str) -> None: slug = self.slug or (self.game.slug if self.game else "") service_media = self.service_medias[image_type] if self.game: self.game.custom_images.add(image_type) dest_paths = [mp.path for mp in service_media.get_possible_media_paths(slug)] if image_path not in dest_paths: ext = get_image_file_extension(image_path) if ext: for candidate in dest_paths: if candidate.casefold().endswith(ext): self._save_copied_media_to(candidate, image_type, image_path) return self._save_transcoded_media_to(dest_paths[0], image_type, image_path) def _save_copied_media_to(self, dest_path: str, image_type: str, image_path: str) -> None: """Copies a media file to the dest_path, but trashes the existing media for the game first. When complete, this updates the button indicated by image_type as well.""" slug = self.slug or (self.game.slug if self.game else "") service_media = self.service_medias[image_type] def on_trashed(): AsyncCall(copy_image, self.image_refreshed_cb) def copy_image(): shutil.copy(image_path, dest_path, follow_symlinks=True) MEDIA_CACHE_INVALIDATED.fire() return image_type service_media.trash_media(slug, completion_function=on_trashed) def _save_transcoded_media_to(self, dest_path: str, image_type: str, image_path: str) -> None: """Transcode an image, copying it to a new path and selecting the file type based on the file extension of dest_path. Trashes all media for the current game too. Runs in the background, and when complete updates the button indicated by image_type.""" slug = self.slug or (self.game.slug if self.game else "") service_media = self.service_medias[image_type] ext = get_image_file_extension(dest_path) or ".png" file_format = {".jpg": "jpeg", ".png": "png"}[ext] # If we must transcode the image, we'll scale the image up based on # the UI scale factor, to try to avoid blurriness. Of course this won't # work if the user changes the scaling later, but what can you do. scale_factor = self.get_scale_factor() width, height = service_media.custom_media_storage_size width = width * scale_factor height = height * scale_factor temp_file = dest_path + ".tmp" def transcode(): pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(image_path, width, height) # JPEG encoding looks rather better at high quality; # PNG encoding just ignores this option. pixbuf.savev(temp_file, file_format, ["quality"], ["100"]) service_media.trash_media(slug, completion_function=on_trashed) def transcode_cb(_result, error): if error: raise error def on_trashed(): os.rename(temp_file, dest_path) MEDIA_CACHE_INVALIDATED.fire() self.image_refreshed_cb(image_type) AsyncCall(transcode, transcode_cb) def refresh_image(self, image_type): slug = self.slug or (self.game.slug if self.game else "") service_media = self.service_medias[image_type] if self.game: self.game.custom_images.discard(image_type) def on_trashed(): AsyncCall(download, self.image_refreshed_cb) def download(): download_lutris_media(slug) return image_type service_media.trash_media(slug, completion_function=on_trashed) def image_refreshed_cb(self, image_type, _error=None): if image_type: self._set_image(image_type, self.image_buttons[image_type]) service_media = self.service_medias[image_type] service_media.run_system_update_desktop_icons() # Refresh the text entry and browse button with the new image path self.refresh_image_path_entry(image_type) class UrlDialog(Gtk.Dialog): def __init__(self, parent): super().__init__(title=_("Enter URL"), transient_for=parent, flags=0) self.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK) self.set_default_size(300, 100) box = self.get_content_area() self.entry = Gtk.Entry() self.entry.set_placeholder_text("https://example.com/image.png") box.add(self.entry) self.show_all() def get_url(self): return self.entry.get_text()