feat: mod enqueue logic (WIP)

This commit is contained in:
aclist 2026-05-07 06:30:33 +09:00
parent 0e6caed64d
commit fbbf0d9e66
8 changed files with 148 additions and 92 deletions

View File

@ -160,3 +160,13 @@ def find_stale_mods(config: Path) -> list[int]:
unique_mods = set(remote_mods)
stale = [mod for mod in local if mod not in unique_mods]
return stale
def get_mod_dir_size(path: Path) -> int:
size = 0
for i in Path(path).iterdir():
if i.is_file():
size += i.stat().st_size
elif i.is_dir():
size += get_mod_dir_size(i)
return size

View File

@ -8,50 +8,12 @@ from pathlib import Path
from dzgui.const.constants import APP_NAME
from dzgui.const.endpoints import STEAM_PUBLISHED_FILES
from dzgui.util.bash import concat_bash_args
logger = logging.getLogger(APP_NAME)
def query_defunct() -> None:
pass
# TODO: unimplemented
# cf.
#!/usr/bin/env bash
# query_defunct(){
# readarray -t modlist <<< "$@"
# local max=${#modlist[@]}
# concat(){
# for ((i=0;i<$max;i++)); do
# echo "publishedfileids[$i]=${modlist[$i]}&"
# done | awk '{print}' ORS=''
# }
# payload(){
# echo -e "itemcount=${max}&$(concat)"
# }
# post(){
# curl -s \
# -X POST \
# -H "Content-Type:application/x-www-form-urlencoded"\
# -d "$(payload)" 'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json'
# }
# post | jq
# return
# local result=$(post | jq -r '
# .[].publishedfiledetails[]
# | select(.result==1)
# | select(.filename|contains("screenshot")|not)
# | "\(.file_size) \(.publishedfileid)"')
# <<< "$result" awk '{print $2}'
# }
#
# query_defunct "3576065083"
def concat_mods(mods: list[int]) -> str:
def concat_mods(mods: list[str]) -> str:
for mod in mods:
mods[mod] = f"@{mod}"
return ";".join(mods)
@ -68,22 +30,32 @@ def get_local_signatures(version_file: Path) -> dict[str, int]:
return hashes
def enqueue_mod(mod: str, appid: int) -> None:
args = [
"steam",
f"steam://url/CommunityFilePage/{mod}+workshop_download_item",
str(appid),
mod,
]
subprocess.Popen(["/usr/bin/env", "bash", *args])
def get_needs_update(
version_file: Path, remote_hashes: list[tuple[str, int, str]]
) -> list[tuple[str, int, str]]:
version_file: Path, remote_hashes: list[tuple[str, str, int, int]]
) -> list[tuple[str, str, int, int]]:
local_hashes = get_local_signatures(version_file)
needs_update: list[tuple[str, str]] = []
for _id, _hash, size in remote_hashes:
for title, _id, _hash, size in remote_hashes:
if _id not in local_hashes:
needs_update.append((_id, _hash, size))
needs_update.append((title, _id, _hash, size))
elif _hash != local_hashes[_id]:
needs_update.append((_id, _hash, size))
needs_update.append((title, _id, _hash, size))
else:
continue
return needs_update
def get_remote_signatures(mods: list[str]) -> list[tuple[str, int]]:
def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]:
"""
Attempts to continue connecting even if signatures are empty
"""
@ -99,24 +71,26 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, int]]:
if r.status_code != 200:
return []
hashes: list[tuple[str, int, str]] = []
hashes: list[tuple[str, int, int]] = []
j = r.json()
rows = j["response"]["publishedfiledetails"]
for row in rows:
title = row["title"]
_id = row["publishedfileid"]
time = row["time_updated"]
size = row["file_size"]
hashes.append((_id, time, size))
size = int(row["file_size"])
hashes.append((title, _id, time, size))
return hashes
# TEST: set config to name=user, use official server and no mods,
# ensure that formatted string is identical to fixture
def connect(addr: str, appid: int, name: str, mods: list[int]) -> None:
# TODO: get name from configs
# TODO: concat_mods(mods):
# @<mod>;@<mod>;
# TODO:: set config to name=user, use official server and no mods,
# ensure that formatted string is identical to fixture with same hash
def connect(addr: str, appid: int, name: str, mods: list[str]) -> None:
concat = concat_mods(mods)
params = [
"steam",
"-applaunch",
appid,
f"-connect={addr}",
"-nolauncher",
"-nosplash",
@ -124,10 +98,7 @@ def connect(addr: str, appid: int, name: str, mods: list[int]) -> None:
f"-name={name}",
f"-mod={concat}",
]
# TODO: get steam launch command from configs
# args = concat_bash_args()
#
proc = subprocess.Popen([*args, "-applaunch", appid, *params])
proc = subprocess.Popen([*params])
# check proc.returncode

View File

@ -112,7 +112,7 @@ class NotebookPage(EnumWithAttrs):
OPTIONS = {"crumbs": strings.crumbs.options, "statusbar": False}
SERVERS = {"crumbs": strings.crumbs.servers, "statusbar": True}
THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": True}
CONNECTION = {"crumbs": "Connect", "statusbar": False}
CONNECTION = {"crumbs": "Connect", "statusbar": True}
class RowType(EnumWithAttrs):

View File

@ -20,6 +20,7 @@ from dzgui.controllers.emitter import Emitter
from dzgui.managers.config import ConfigManager
from dzgui.managers.connection import ConnectionManager
from dzgui.managers.contextmenu import ContextMenuManager
from dzgui.managers.mods import ModManager
from dzgui.managers.notes import NoteManager
from dzgui.model.servers import ServerModelManager
from dzgui.util.diag import write_diagnostic
@ -215,8 +216,6 @@ class Controller(GObject.GObject):
open_user_workshop(uid, client)
def load_mods(self) -> None:
from dzgui.managers.mods import ModManager
self.mod_man = ModManager(self)
def uncolorize_mods(self) -> None:
@ -473,3 +472,6 @@ class Controller(GObject.GObject):
def update_and_connect(self) -> None:
self.connection_man.update_and_connect()
def update_status(self, mod: str, mark_finished: bool = False) -> None:
self.mediator.preconnect.update_mod(mod, mark_finished)

View File

@ -1,5 +1,6 @@
import logging
import shutil
import time
from dataclasses import dataclass
from packaging.version import Version
@ -8,9 +9,9 @@ from typing import TYPE_CHECKING
import dzgui.api.pefile as PeFile
import dzgui.api.servers as Servers
from dzgui.api.steam import get_remote_signatures, get_needs_update
from dzgui.api.steam import enqueue_mod, get_remote_signatures, get_needs_update
from dzgui.api.mods import get_local_mod_ids
from dzgui.api.mods import get_mod_dir_size, get_local_mod_ids, get_local_mod_path
from dzgui.const.constants import (
APP_NAME,
APPID_DAYZ,
@ -29,7 +30,7 @@ from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
from gi.repository import Gtk, GLib # noqa E402
if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo, Record
@ -62,6 +63,7 @@ class ConnectionManager:
self.appid: int
self.record: Record
self.workshop: Path
self.remote_mod_ids: list[str] = []
self.missing_mods: list[str] = []
@ -101,6 +103,7 @@ class ConnectionManager:
free_mib = 0.0
steam_path = Path(self.controller.query_config(Preferences.DEFAULT))
self.workshop = get_local_mod_path(steam_path)
local_version = PeFile.get_pretty_version(steam_path, info.game_id)
if local_version is None:
local_version = "0.0.0"
@ -124,13 +127,12 @@ class ConnectionManager:
pefile_path = PeFile.get_pefile_path(steam_path, info.game_id)
total, used, free = shutil.disk_usage(pefile_path)
if len(self.missing_mods) > 0:
required_size = sum(int(row[2]) for row in self.missing_mods)
required_size = sum(row[2] for row in self.missing_mods)
required_mib = format_mib(required_size)
free_mib = format_mib(required_size)
dayz_running = is_dayz_running()
steam_running = is_steam_running()
# TODO: is dayz downloading
prereqs = Prerequisites(
name=info.server_name,
@ -207,17 +209,38 @@ class ConnectionManager:
print(self.record.ip)
print(self.record.gameport)
print(self.appid)
# TODO: convert mod ids to symlink hashes
# TODO: add to history file and list store
# steam api, concat mods
# TODO: custom threading with glib idle callback
@call_on_thread("Waiting for Steam to update mods")
def update_mods(self) -> None:
print(self.missing_mods)
# TODO: when downloading mods, create symlinks if missing
# TODO: pack a final PreReq struct with pre-processed values
# self.needs_update
# then connect
pass
# NOTE: fast enqueue all mods in auto mode
for title, mod, stamp, size in self.missing_mods:
# TODO: check for cancel event
enqueue_mod(mod, self.appid)
time.sleep(2)
for title, mod, stamp, size in self.missing_mods:
mod_path = self.workshop / mod
while mod_path.is_dir() is False:
time.sleep(1)
while True:
# NOTE: mods will finish at the same time
# TODO: check for cancel event
cur_size = get_mod_dir_size(mod_path)
if cur_size == size:
break
time.sleep(1)
# TODO: update tree checkmarks
func = StoredFunc(self.controller.update_status, "All mods updated.")
self.thread_man.set_cleanup_func(func)
# TODO: after downloading mods, create all symlinks (and clone)
# GLib.idle_add(self._mark_finished)
# def _mark_finished(self) -> None:
# self.controller.update_status("All mods updated.", mark_finished=True)
def update_and_connect(self) -> None:
if len(self.missing_mods) > 0:

View File

@ -1,4 +1,5 @@
import shlex
def concat_bash_args(command: str) -> list[str]:
return shlex.split(command)

View File

@ -76,6 +76,7 @@ class Statusbar(Gtk.Grid):
NotebookPage.THANKS,
NotebookPage.CHANGELOG,
NotebookPage.LOG,
NotebookPage.CONNECTION,
):
self.set_by_context(enum, esc_to_return)
return

View File

@ -5,6 +5,7 @@ from dzgui.const.constants import (
ERROR,
WARNING,
)
from dzgui.const.enum import NotebookPage
from dzgui.util.css import add_class
from dzgui.util.localize import number
from dzgui.strings import preconnect
@ -99,20 +100,33 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
self.controller.register_widget("preconnect", self)
self.back = Gtk.Button(label=preconnect.back, halign=Gtk.Align.START)
self.cancel = Gtk.Button(
label=preconnect.cancel, halign=Gtk.Align.END, sensitive=False, hexpand=True
self.back = Gtk.Button(
label=preconnect.back, halign=Gtk.Align.END, hexpand=True
)
self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END)
# TODO: abstract
self.raise_window = Gtk.CheckButton(
label="Foreground DZGUI while downloading",
halign=Gtk.Align.END,
hexpand=True,
valign=Gtk.Align.END,
visible=False,
has_tooltip=True,
sensitive=False,
tooltip_text="This option is available if wmctrl or xdotool is installed on the system",
)
self.button_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
orientation=Gtk.Orientation.VERTICAL,
valign=Gtk.Align.END,
vexpand=True,
spacing=5,
)
for button in self.back, self.cancel, self.ok:
self.button_box.add(button)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
for button in self.back, self.ok:
box.add(button)
for el in self.raise_window, box:
self.button_box.add(el)
self.back.connect("clicked", self._on_back_clicked)
self.ok.connect("clicked", self._on_ok_clicked)
@ -128,6 +142,21 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
margin_top=10,
margin_bottom=10,
)
self.spinner = Gtk.Spinner()
self.progress_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15)
self.progress_box.add(self.mod_count)
self.progress_box.add(self.spinner)
self.cancel = Gtk.Button(
label=preconnect.cancel,
halign=Gtk.Align.START,
hexpand=True,
margin_bottom=5,
margin_top=5,
)
self.progress_box.add(self.cancel)
self.cancel.connect("clicked", self._on_cancel_clicked)
self.cancel.set_visible(False)
# TODO: live count of remaining downloads
# "Steam is downloading: {mod_name}"
# mention whether manual or auto mod is active
@ -138,7 +167,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
self.tree_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.tree_box.add(self.scrolled)
self.tree_box.add(self.mod_count)
self.tree_box.add(self.progress_box)
# TODO: strings
self.mods_placeholder = Placeholder("This server has no mods.")
@ -180,12 +209,18 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
self.grab_focus()
widgets = (
self.tree_frame,
self.mod_count,
self.progress_box,
self.error_placeholder,
self.warning_placeholder,
)
for widget in widgets:
widget.set_visible(True)
self.raise_window.set_visible(False)
# TODO: enable button if wmctrl or xdotool is available
# TODO: check this at boot time and pass in via connection manager
self.cancel.set_visible(False)
self.ok.set_sensitive(True)
self.ok.set_label(preconnect.update_mods)
@ -193,18 +228,24 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
if event.keyval == Gdk.KEY_Escape:
self.back.emit("clicked")
def _on_cancel_clicked(self, button: Gtk.Button) -> None:
print("user canceled")
def _on_ok_clicked(self, button: Gtk.Button) -> None:
# TODO: update mod store in place with spinner/toast
# no dialog
# TODO: cancel mod downloads
# TODO: add to history file and list store
# TODO: concat mods
# sets some kind of global event listener
if self.mod_count.get_visible():
# TODO: set ready mode
# TODO: strings
self.mod_count.set_label("Enqueuing downloads")
self.spinner.start()
self.cancel.set_visible(True)
self.ok.set_label(preconnect.connect)
self.controller.update_and_connect()
pass
def _on_back_clicked(self, button: Gtk.Button) -> None:
page = self.controller.get_prior_page()
self.controller.open_page(page)
self.controller.open_page(NotebookPage.SERVERS)
def _process_warnings(self, prereqs: "Prerequisites") -> None:
warnings: list[str] = []
@ -256,16 +297,17 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
if total_mods < 1:
self.scrolled.set_visible(False)
self.mod_count.set_visible(False)
self.progress_box.set_visible(False)
self.mods_placeholder.set_visible(True)
self.ok.set_label(preconnect.connect)
else:
self.scrolled.set_visible(True)
self.mod_count.set_visible(True)
self.progress_box.set_visible(True)
self.raise_window.set_visible(True)
self.mods_placeholder.set_visible(False)
# TODO: print no. of mods that need updating
suffix = ""
# TODO: strings
suffix = "All mods are up to date."
if prereqs.required_space > 0:
pretty = number(prereqs.required_space)
suffix = f" Need to download {pretty} MiB of mod updates."
@ -274,6 +316,12 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
self._process_warnings(prereqs)
def update_mod(self, text: str, mark_finished: bool = False) -> None:
self.mod_count.set_label(text)
if mark_finished:
self.cancel.set_visible(False)
self.spinner.stop()
def add_errors(self, errors: list[str]) -> None:
self.error_tree.extend(errors)