feat: mod enqueue logic (WIP)

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

No files matched your search

+10
View File
@@ -160,3 +160,13 @@ def find_stale_mods(config: Path) -> list[int]:
unique_mods = set(remote_mods) unique_mods = set(remote_mods)
stale = [mod for mod in local if mod not in unique_mods] stale = [mod for mod in local if mod not in unique_mods]
return stale 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
+29 -58
View File
@@ -8,50 +8,12 @@ from pathlib import Path
from dzgui.const.constants import APP_NAME from dzgui.const.constants import APP_NAME
from dzgui.const.endpoints import STEAM_PUBLISHED_FILES from dzgui.const.endpoints import STEAM_PUBLISHED_FILES
from dzgui.util.bash import concat_bash_args
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
def query_defunct() -> None: def concat_mods(mods: list[str]) -> str:
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:
for mod in mods: for mod in mods:
mods[mod] = f"@{mod}" mods[mod] = f"@{mod}"
return ";".join(mods) return ";".join(mods)
@@ -68,22 +30,32 @@ def get_local_signatures(version_file: Path) -> dict[str, int]:
return hashes 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( def get_needs_update(
version_file: Path, remote_hashes: list[tuple[str, int, str]] version_file: Path, remote_hashes: list[tuple[str, str, int, int]]
) -> list[tuple[str, int, str]]: ) -> list[tuple[str, str, int, int]]:
local_hashes = get_local_signatures(version_file) local_hashes = get_local_signatures(version_file)
needs_update: list[tuple[str, str]] = [] 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: if _id not in local_hashes:
needs_update.append((_id, _hash, size)) needs_update.append((title, _id, _hash, size))
elif _hash != local_hashes[_id]: elif _hash != local_hashes[_id]:
needs_update.append((_id, _hash, size)) needs_update.append((title, _id, _hash, size))
else: else:
continue continue
return needs_update 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 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: if r.status_code != 200:
return [] return []
hashes: list[tuple[str, int, str]] = [] hashes: list[tuple[str, int, int]] = []
j = r.json() j = r.json()
rows = j["response"]["publishedfiledetails"] rows = j["response"]["publishedfiledetails"]
for row in rows: for row in rows:
title = row["title"]
_id = row["publishedfileid"] _id = row["publishedfileid"]
time = row["time_updated"] time = row["time_updated"]
size = row["file_size"] size = int(row["file_size"])
hashes.append((_id, time, size)) hashes.append((title, _id, time, size))
return hashes return hashes
# TEST: set config to name=user, use official server and no mods, # TODO:: set config to name=user, use official server and no mods,
# ensure that formatted string is identical to fixture # ensure that formatted string is identical to fixture with same hash
def connect(addr: str, appid: int, name: str, mods: list[int]) -> None: def connect(addr: str, appid: int, name: str, mods: list[str]) -> None:
# TODO: get name from configs concat = concat_mods(mods)
# TODO: concat_mods(mods):
# @<mod>;@<mod>;
params = [ params = [
"steam",
"-applaunch",
appid,
f"-connect={addr}", f"-connect={addr}",
"-nolauncher", "-nolauncher",
"-nosplash", "-nosplash",
@@ -124,10 +98,7 @@ def connect(addr: str, appid: int, name: str, mods: list[int]) -> None:
f"-name={name}", f"-name={name}",
f"-mod={concat}", f"-mod={concat}",
] ]
# TODO: get steam launch command from configs proc = subprocess.Popen([*params])
# args = concat_bash_args()
#
proc = subprocess.Popen([*args, "-applaunch", appid, *params])
# check proc.returncode # check proc.returncode
+1 -1
View File
@@ -112,7 +112,7 @@ class NotebookPage(EnumWithAttrs):
OPTIONS = {"crumbs": strings.crumbs.options, "statusbar": False} OPTIONS = {"crumbs": strings.crumbs.options, "statusbar": False}
SERVERS = {"crumbs": strings.crumbs.servers, "statusbar": True} SERVERS = {"crumbs": strings.crumbs.servers, "statusbar": True}
THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": True} THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": True}
CONNECTION = {"crumbs": "Connect", "statusbar": False} CONNECTION = {"crumbs": "Connect", "statusbar": True}
class RowType(EnumWithAttrs): class RowType(EnumWithAttrs):
+4 -2
View File
@@ -20,6 +20,7 @@ from dzgui.controllers.emitter import Emitter
from dzgui.managers.config import ConfigManager from dzgui.managers.config import ConfigManager
from dzgui.managers.connection import ConnectionManager from dzgui.managers.connection import ConnectionManager
from dzgui.managers.contextmenu import ContextMenuManager from dzgui.managers.contextmenu import ContextMenuManager
from dzgui.managers.mods import ModManager
from dzgui.managers.notes import NoteManager from dzgui.managers.notes import NoteManager
from dzgui.model.servers import ServerModelManager from dzgui.model.servers import ServerModelManager
from dzgui.util.diag import write_diagnostic from dzgui.util.diag import write_diagnostic
@@ -215,8 +216,6 @@ class Controller(GObject.GObject):
open_user_workshop(uid, client) open_user_workshop(uid, client)
def load_mods(self) -> None: def load_mods(self) -> None:
from dzgui.managers.mods import ModManager
self.mod_man = ModManager(self) self.mod_man = ModManager(self)
def uncolorize_mods(self) -> None: def uncolorize_mods(self) -> None:
@@ -473,3 +472,6 @@ class Controller(GObject.GObject):
def update_and_connect(self) -> None: def update_and_connect(self) -> None:
self.connection_man.update_and_connect() 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)
+36 -13
View File
@@ -1,5 +1,6 @@
import logging import logging
import shutil import shutil
import time
from dataclasses import dataclass from dataclasses import dataclass
from packaging.version import Version from packaging.version import Version
@@ -8,9 +9,9 @@ from typing import TYPE_CHECKING
import dzgui.api.pefile as PeFile import dzgui.api.pefile as PeFile
import dzgui.api.servers as Servers 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 ( from dzgui.const.constants import (
APP_NAME, APP_NAME,
APPID_DAYZ, APPID_DAYZ,
@@ -29,7 +30,7 @@ from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk, GLib # noqa E402
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo, Record from dzgui.api.servers import A2SInfo, Record
@@ -62,6 +63,7 @@ class ConnectionManager:
self.appid: int self.appid: int
self.record: Record self.record: Record
self.workshop: Path
self.remote_mod_ids: list[str] = [] self.remote_mod_ids: list[str] = []
self.missing_mods: list[str] = [] self.missing_mods: list[str] = []
@@ -101,6 +103,7 @@ class ConnectionManager:
free_mib = 0.0 free_mib = 0.0
steam_path = Path(self.controller.query_config(Preferences.DEFAULT)) 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) local_version = PeFile.get_pretty_version(steam_path, info.game_id)
if local_version is None: if local_version is None:
local_version = "0.0.0" local_version = "0.0.0"
@@ -124,13 +127,12 @@ class ConnectionManager:
pefile_path = PeFile.get_pefile_path(steam_path, info.game_id) pefile_path = PeFile.get_pefile_path(steam_path, info.game_id)
total, used, free = shutil.disk_usage(pefile_path) total, used, free = shutil.disk_usage(pefile_path)
if len(self.missing_mods) > 0: 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) required_mib = format_mib(required_size)
free_mib = format_mib(required_size) free_mib = format_mib(required_size)
dayz_running = is_dayz_running() dayz_running = is_dayz_running()
steam_running = is_steam_running() steam_running = is_steam_running()
# TODO: is dayz downloading
prereqs = Prerequisites( prereqs = Prerequisites(
name=info.server_name, name=info.server_name,
@@ -207,17 +209,38 @@ class ConnectionManager:
print(self.record.ip) print(self.record.ip)
print(self.record.gameport) print(self.record.gameport)
print(self.appid) print(self.appid)
# TODO: convert mod ids to symlink hashes # TODO: add to history file and list store
# steam api, concat mods # 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: def update_mods(self) -> None:
print(self.missing_mods) # NOTE: fast enqueue all mods in auto mode
# TODO: when downloading mods, create symlinks if missing for title, mod, stamp, size in self.missing_mods:
# TODO: pack a final PreReq struct with pre-processed values # TODO: check for cancel event
# self.needs_update enqueue_mod(mod, self.appid)
# then connect time.sleep(2)
pass
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: def update_and_connect(self) -> None:
if len(self.missing_mods) > 0: if len(self.missing_mods) > 0:
+1
View File
@@ -1,4 +1,5 @@
import shlex import shlex
def concat_bash_args(command: str) -> list[str]: def concat_bash_args(command: str) -> list[str]:
return shlex.split(command) return shlex.split(command)
+1
View File
@@ -76,6 +76,7 @@ class Statusbar(Gtk.Grid):
NotebookPage.THANKS, NotebookPage.THANKS,
NotebookPage.CHANGELOG, NotebookPage.CHANGELOG,
NotebookPage.LOG, NotebookPage.LOG,
NotebookPage.CONNECTION,
): ):
self.set_by_context(enum, esc_to_return) self.set_by_context(enum, esc_to_return)
return return
+66 -18
View File
@@ -5,6 +5,7 @@ from dzgui.const.constants import (
ERROR, ERROR,
WARNING, WARNING,
) )
from dzgui.const.enum import NotebookPage
from dzgui.util.css import add_class from dzgui.util.css import add_class
from dzgui.util.localize import number from dzgui.util.localize import number
from dzgui.strings import preconnect from dzgui.strings import preconnect
@@ -99,20 +100,33 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
self.controller.register_widget("preconnect", self) self.controller.register_widget("preconnect", self)
self.back = Gtk.Button(label=preconnect.back, halign=Gtk.Align.START) self.back = Gtk.Button(
self.cancel = Gtk.Button( label=preconnect.back, halign=Gtk.Align.END, hexpand=True
label=preconnect.cancel, halign=Gtk.Align.END, sensitive=False, hexpand=True
) )
self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END) 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( self.button_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, orientation=Gtk.Orientation.VERTICAL,
valign=Gtk.Align.END, valign=Gtk.Align.END,
vexpand=True, vexpand=True,
spacing=5, spacing=5,
) )
for button in self.back, self.cancel, self.ok: box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
self.button_box.add(button) 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.back.connect("clicked", self._on_back_clicked)
self.ok.connect("clicked", self._on_ok_clicked) self.ok.connect("clicked", self._on_ok_clicked)
@@ -128,6 +142,21 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
margin_top=10, margin_top=10,
margin_bottom=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 # TODO: live count of remaining downloads
# "Steam is downloading: {mod_name}" # "Steam is downloading: {mod_name}"
# mention whether manual or auto mod is active # 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 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.tree_box.add(self.scrolled) self.tree_box.add(self.scrolled)
self.tree_box.add(self.mod_count) self.tree_box.add(self.progress_box)
# TODO: strings # TODO: strings
self.mods_placeholder = Placeholder("This server has no mods.") self.mods_placeholder = Placeholder("This server has no mods.")
@@ -180,12 +209,18 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
self.grab_focus() self.grab_focus()
widgets = ( widgets = (
self.tree_frame, self.tree_frame,
self.mod_count, self.progress_box,
self.error_placeholder, self.error_placeholder,
self.warning_placeholder, self.warning_placeholder,
) )
for widget in widgets: for widget in widgets:
widget.set_visible(True) 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_sensitive(True)
self.ok.set_label(preconnect.update_mods) self.ok.set_label(preconnect.update_mods)
@@ -193,18 +228,24 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
if event.keyval == Gdk.KEY_Escape: if event.keyval == Gdk.KEY_Escape:
self.back.emit("clicked") 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: 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: cancel mod downloads
# TODO: add to history file and list store # sets some kind of global event listener
# TODO: concat mods 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() self.controller.update_and_connect()
pass pass
def _on_back_clicked(self, button: Gtk.Button) -> None: def _on_back_clicked(self, button: Gtk.Button) -> None:
page = self.controller.get_prior_page() self.controller.open_page(NotebookPage.SERVERS)
self.controller.open_page(page)
def _process_warnings(self, prereqs: "Prerequisites") -> None: def _process_warnings(self, prereqs: "Prerequisites") -> None:
warnings: list[str] = [] warnings: list[str] = []
@@ -256,16 +297,17 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
if total_mods < 1: if total_mods < 1:
self.scrolled.set_visible(False) self.scrolled.set_visible(False)
self.mod_count.set_visible(False) self.progress_box.set_visible(False)
self.mods_placeholder.set_visible(True) self.mods_placeholder.set_visible(True)
self.ok.set_label(preconnect.connect) self.ok.set_label(preconnect.connect)
else: else:
self.scrolled.set_visible(True) 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) self.mods_placeholder.set_visible(False)
# TODO: print no. of mods that need updating # TODO: strings
suffix = "" suffix = "All mods are up to date."
if prereqs.required_space > 0: if prereqs.required_space > 0:
pretty = number(prereqs.required_space) pretty = number(prereqs.required_space)
suffix = f" Need to download {pretty} MiB of mod updates." suffix = f" Need to download {pretty} MiB of mod updates."
@@ -274,6 +316,12 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
self._process_warnings(prereqs) 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: def add_errors(self, errors: list[str]) -> None:
self.error_tree.extend(errors) self.error_tree.extend(errors)