Move the missing game ids set into a singleton class that updates it into the background and fires a notification when there are changes.

This lets the UI update in a non-blocking way, including the cell-renderer stuff.

The cell-renderer queues re-checks for games it sees are missing, so we'll 'notice' if you replace a missing game.

The implementation at this point is rather complex for what this is, but it seems to work.
This commit is contained in:
Daniel Johnson 2024-01-19 19:28:02 -05:00 committed by Mathieu Comandon
parent d3d8e18a9e
commit 6db8767ef3
5 changed files with 124 additions and 27 deletions

View file

@ -28,7 +28,7 @@ from lutris.gui.widgets.gi_composites import GtkTemplate
from lutris.gui.widgets.sidebar import LutrisSidebar
from lutris.gui.widgets.utils import load_icon_theme, open_uri
from lutris.runtime import ComponentUpdater, RuntimeUpdater
from lutris.scanners.lutris import add_to_path_cache, get_missing_game_ids, remove_from_path_cache
from lutris.scanners.lutris import MISSING_GAMES, add_to_path_cache, remove_from_path_cache
# pylint: disable=no-member
from lutris.services.base import BaseService
from lutris.services.lutris import LutrisService
@ -148,6 +148,7 @@ class LutrisWindow(Gtk.ApplicationWindow,
GObject.add_emission_hook(Game, "game-removed", self.on_game_removed)
GObject.add_emission_hook(Game, "game-unhandled-error", self.on_game_unhandled_error)
GObject.add_emission_hook(PreferencesDialog, "settings-changed", self.on_settings_changed)
MISSING_GAMES.updated.register(self.update_missing_games_sidebar_row)
# Finally trigger the initialization of the view here
selected_category = settings.read_setting("selected_category", default="runner:all")
@ -254,7 +255,7 @@ class LutrisWindow(Gtk.ApplicationWindow,
def on_load(self, widget, data=None):
"""Finish initializing the view"""
self._bind_zoom_adjustment()
AsyncCall(get_missing_game_ids, self.on_get_missing_game_ids)
MISSING_GAMES.update_all_missing()
self.current_view.grab_focus()
def on_sidebar_realize(self, widget, data=None):
@ -417,23 +418,18 @@ class LutrisWindow(Gtk.ApplicationWindow,
"""Return a list of currently running games"""
return games_db.get_games_by_ids(self.application.get_running_game_ids())
def on_get_missing_game_ids(self, missing_ids, error):
if error:
logger.error(str(error))
return
self.get_missing_games(missing_ids)
def get_missing_games(self):
return games_db.get_games_by_ids(MISSING_GAMES.missing_game_ids)
def get_missing_games(self, missing_ids: list = None) -> list:
if missing_ids is None:
missing_ids = get_missing_game_ids()
missing_games = games_db.get_games_by_ids(missing_ids)
def update_missing_games_sidebar_row(self) -> None:
missing_games = self.get_missing_games()
if missing_games:
self.sidebar.missing_row.show()
else:
missing_ids = MISSING_GAMES.missing_game_ids
if missing_ids:
logger.warning("Path cache out of date? (%s IDs missing)", len(missing_ids))
self.sidebar.missing_row.hide()
return missing_games
def get_recent_games(self):
"""Return a list of currently running games"""
@ -1126,7 +1122,8 @@ class LutrisWindow(Gtk.ApplicationWindow,
def on_game_removed(self, game):
"""Simple method used to refresh the view"""
remove_from_path_cache(game)
self.get_missing_games()
MISSING_GAMES.update_missing([game.id])
self.update_missing_games_sidebar_row()
self.emit("view-updated")
return True

View file

@ -9,6 +9,7 @@ from lutris.game import Game
from lutris.game_actions import BaseGameActions, get_game_actions
from lutris.gui.widgets.contextual_menu import ContextualMenu
from lutris.gui.widgets.utils import MEDIA_CACHE_INVALIDATED
from lutris.scanners.lutris import MISSING_GAMES
from lutris.util.log import logger
@ -22,12 +23,14 @@ class GameView:
def __init__(self, service):
self.service = service
self.cache_notification_id = None
self.missing_games_updated_id = None
self.game_start_hook_id = None
self.image_renderer = None
def connect_signals(self):
"""Signal handlers common to all views"""
self.cache_notification_id = MEDIA_CACHE_INVALIDATED.register(self.on_media_cache_invalidated)
self.missing_games_updated_id = MISSING_GAMES.updated.register(self.on_missing_games_updated)
self.connect("destroy", self.on_destroy)
self.connect("button-press-event", self.popup_contextual_menu)
@ -38,10 +41,17 @@ class GameView:
def on_media_cache_invalidated(self):
self.queue_draw()
def on_missing_games_updated(self):
if self.image_renderer and self.image_renderer.show_badges:
self.queue_draw()
def on_destroy(self, _widget):
if self.cache_notification_id:
MEDIA_CACHE_INVALIDATED.unregister(self.cache_notification_id)
if self.missing_games_updated_id:
MISSING_GAMES.updated.unregister(self.missing_games_updated_id)
if self.game_start_hook_id:
GObject.remove_emission_hook(Game, "game-start", self.game_start_hook_id)

View file

@ -14,7 +14,7 @@ from lutris.exceptions import MissingMediaError
from lutris.gui.widgets.utils import (
MEDIA_CACHE_INVALIDATED, get_default_icon_path, get_runtime_icon_path, get_scaled_surface_by_path, get_surface_size
)
from lutris.scanners.lutris import is_game_missing
from lutris.scanners.lutris import MISSING_GAMES
class GridViewCellRendererText(Gtk.CellRendererText):
@ -248,8 +248,9 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
if self.show_badges:
self.render_platforms(cr, widget, surface, 0, cell_area)
if self.game_id and is_game_missing(self.game_id):
if self.game_id and self.game_id in MISSING_GAMES.missing_game_ids:
self.render_text_badge(cr, widget, _("Missing"), 0, cell_area.y + cell_area.height)
MISSING_GAMES.update_missing([self.game_id])
else:
cr.push_group()
self.render_media(cr, widget, surface, 0, 0)

View file

@ -434,6 +434,7 @@ class LutrisSidebar(Gtk.ListBox):
# I wanted this to be on top but it really messes with the headers when showing/hiding the row.
self.add(self.running_row)
self.show_all()
self.missing_row.hide()
self.running_row.hide()
# Create the dynamic rows that are initially needed

View file

@ -1,15 +1,18 @@
import json
import os
import time
from typing import Iterable
from lutris import settings
from lutris.api import get_api_games, get_game_installers
from lutris.database.games import get_games
from lutris.game import Game
from lutris.gui.widgets import NotificationSource
from lutris.installer.errors import MissingGameDependencyError
from lutris.installer.interpreter import ScriptInterpreter
from lutris.services.lutris import download_lutris_media
from lutris.util import cache_single
from lutris.util.jobs import AsyncCall
from lutris.util.log import logger
from lutris.util.strings import slugify
@ -206,17 +209,102 @@ def read_path_cache():
return {}
def get_missing_game_ids():
"""Return a list of IDs for games that can't be found"""
logger.debug("Checking for missing games")
missing_ids = []
for game_id, path in get_path_cache().items():
if not os.path.exists(path):
missing_ids.append(game_id)
return missing_ids
class MissingGames:
"""This class is a singleton that holds a set of game-ids for games whose directories
are missing. It is updated on a background thread, but there's a NotificationSource ('updated')
that fires when that thread has made changes and exited, so that the UI cab update then."""
CHECK_STALL = 3
def __init__(self):
self.updated = NotificationSource()
self.missing_game_ids = set()
self._check_game_ids = None
self._check_game_queue = []
self._check_all_games = False
self._changed = False
def update_all_missing(self):
"""This starts the check for all games; the actual list of game-ids will be obtained
on the worker thread, and this method will start it."""
self._check_all_games = True
self.update_missing()
def update_missing(self, game_ids: Iterable[str] = None):
"""Starts checking the missing status on a list of games. This starts the worker
thread, but if it is running already, it queues the games. The worker will pause
briefly before processing extra games just to limit the rate of filesystem accesses."""
# The presence of this set indicates that the worker is running
start_fetch = self._check_game_ids is None
if not self._check_game_ids:
self._check_game_ids = set()
if game_ids:
self._check_game_ids |= set(game_ids)
if start_fetch:
initial_delay = self.CHECK_STALL if game_ids else 0
AsyncCall(self._fetch, None, initial_delay)
def _fetch(self, initial_delay=0):
"""This is the method that runs on the worker thread; it continues until all
games are processed, even extras added while it is running."""
time.sleep(initial_delay)
logger.debug("Checking for missing games")
try:
while True:
game_id = self._next_game_id()
if not game_id:
break
path = get_path_cache().get(game_id)
if path:
if os.path.exists(path):
if game_id in self.missing_game_ids:
self.missing_game_ids.discard(game_id)
self._changed = True
elif game_id not in self.missing_game_ids:
self.missing_game_ids.add(game_id)
self._changed = True
except Exception as ex:
logger.exception("Unable to detect missing games: %s", ex)
finally:
# Clearing out _check_game_ids is how we know the worker is no longer running
self._check_game_ids = None
self._notify_changed()
def _notify_changed(self):
"""Fires the 'updated' notification if changes have been made since the last time
this method was called."""
if self._changed:
self._changed = False
self.updated.fire()
def _next_game_id(self):
"""Returns the next game-id to check, or None if we're done. This will detect
additional games once the queue empties, and moves hem to the queue, but fires
the notification then too."""
if self._check_all_games:
self._check_all_games = False
path_cache = get_path_cache()
self._check_game_queue = list(path_cache)
self._check_game_ids.clear()
if not self._check_game_queue and self._check_game_ids:
# If more ids have arrived while fetching the old ones, we do not
# just check them too. We notify immediate and wait a little,
# then continue checking. This will
self._notify_changed()
time.sleep(self.CHECK_STALL)
self._check_game_queue = list(self._check_game_ids)
self._check_game_ids.clear()
if self._check_game_queue:
return self._check_game_queue.pop()
return None
def is_game_missing(game_id):
cache = get_path_cache()
path = cache.get(str(game_id))
return path and not os.path.exists(os.path.expanduser(path))
MISSING_GAMES = MissingGames()