Мод-менеджер с галочками и коллекции мастерской
Вместо двух отдельных списков (Workshop ID и Mod ID) — один список модов: галочка включает мод, перетаскивание задаёт порядок загрузки, кнопка убирает пакет вместе со всеми его модами. Чтобы галочкам было за что зацепиться, панель читает mod.info уже скачанных модов: серверу в Mods нужен именно Mod ID, а он лежит внутри пакета мастерской и вручную его приходилось искать в описании мода. Пока мод не скачан, строка помечена и галочка недоступна — Mod ID неизвестен. Один пакет мастерской может содержать несколько модов; теперь они видны отдельными строками и включаются по отдельности. Коллекции: кнопка разворачивает коллекцию Steam в список входящих в неё модов, сохраняя порядок со страницы коллекции. И мод, и коллекция принимаются как ссылкой, так и голым идентификатором. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,13 +3,20 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||
)
|
||||
|
||||
// handleGetMods отдаёт списки модов из servertest.ini. Названия модов не
|
||||
// запрашиваются здесь: поход в Steam медленный, для него есть отдельный
|
||||
// эндпоинт lookup.
|
||||
// idFromInput вытаскивает числовой идентификатор из того, что ввёл человек:
|
||||
// это может быть сам id или ссылка вида
|
||||
// https://steamcommunity.com/sharedfiles/filedetails/?id=2392709985.
|
||||
var idFromInput = regexp.MustCompile(`(\d{5,})`)
|
||||
|
||||
// handleGetMods отдаёт списки модов профиля и каталог того, что уже скачано на
|
||||
// диск. Названия из мастерской здесь не запрашиваются: поход в Steam медленный,
|
||||
// для него есть отдельный эндпоинт lookup.
|
||||
func (s *Server) handleGetMods(w http.ResponseWriter, r *http.Request) {
|
||||
path := s.cfg.ServerConfigPath()
|
||||
ini, err := pzconfig.LoadINI(path)
|
||||
@@ -25,16 +32,28 @@ func (s *Server) handleGetMods(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
mods, workshop := ini.Mods()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"exists": true,
|
||||
"path": path,
|
||||
"profile": s.cfg.ActiveProfile().ID,
|
||||
"mods": mods,
|
||||
"workshop_items": workshop,
|
||||
"map": pzconfig.SplitList(ini.Get(pzconfig.KeyMap)),
|
||||
"installed": s.installedMods(),
|
||||
})
|
||||
}
|
||||
|
||||
// installedMods собирает каталог скачанных модов. Сервер кладёт мастерскую
|
||||
// рядом с собой, но встречается и раскладка в ~/Zomboid.
|
||||
func (s *Server) installedMods() []pzconfig.InstalledMod {
|
||||
return pzconfig.ScanInstalledMods(
|
||||
[]string{s.cfg.ServerDir, s.cfg.ZomboidDir},
|
||||
filepath.Join(s.cfg.ZomboidDir, "mods"),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutMods(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Mods []string `json:"mods"`
|
||||
@@ -82,3 +101,32 @@ func (s *Server) handleLookupMods(w http.ResponseWriter, r *http.Request) {
|
||||
items := s.workshop.Details(r.Context(), body.IDs)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// handleExpandCollection разворачивает коллекцию мастерской в список модов.
|
||||
// Принимает и голый id, и ссылку на страницу коллекции.
|
||||
func (s *Server) handleExpandCollection(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
match := idFromInput.FindString(body.ID)
|
||||
if match == "" {
|
||||
writeError(w, http.StatusBadRequest, "не похоже на идентификатор или ссылку на коллекцию")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := s.workshop.Collection(r.Context(), match)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
// Названия подтягиваем сразу: иначе панель показала бы десяток голых
|
||||
// чисел и потребовала отдельного запроса.
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"collection": match,
|
||||
"items": s.workshop.Details(r.Context(), items),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ func (s *Server) Handler() http.Handler {
|
||||
"GET /api/mods": s.handleGetMods,
|
||||
"PUT /api/mods": s.handlePutMods,
|
||||
"POST /api/mods/lookup": s.handleLookupMods,
|
||||
"POST /api/mods/collection": s.handleExpandCollection,
|
||||
"GET /api/backups": s.handleListBackups,
|
||||
"POST /api/backups": s.handleCreateBackup,
|
||||
"DELETE /api/backups/{name}": s.handleDeleteBackup,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package pzconfig
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pzAppID — идентификатор Project Zomboid в Steam. По нему сервер
|
||||
// раскладывает скачанные моды мастерской.
|
||||
const pzAppID = "108600"
|
||||
|
||||
// InstalledMod — мод, найденный на диске. Именно ModID сервер ждёт в списке
|
||||
// Mods, а Workshop ID — это пакет мастерской, внутри которого мод лежит.
|
||||
type InstalledMod struct {
|
||||
ModID string `json:"mod_id"`
|
||||
WorkshopID string `json:"workshop_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Dir string `json:"dir"`
|
||||
}
|
||||
|
||||
// ScanInstalledMods обходит места, куда попадают моды, и читает их mod.info.
|
||||
// Панель по этому каталогу показывает настоящие названия и, главное, знает
|
||||
// Mod ID: без него мод нельзя включить, а вручную его приходится искать в
|
||||
// описании на странице мастерской.
|
||||
//
|
||||
// roots — директории, внутри которых ищем steamapps/workshop (директория
|
||||
// сервера и ~/Zomboid), localMods — папка с модами, положенными руками.
|
||||
func ScanInstalledMods(roots []string, localMods string) []InstalledMod {
|
||||
seen := make(map[string]bool)
|
||||
var out []InstalledMod
|
||||
|
||||
add := func(mod InstalledMod) {
|
||||
// Один и тот же мод может лежать и в мастерской, и в локальной папке.
|
||||
if mod.ModID == "" || seen[mod.ModID] {
|
||||
return
|
||||
}
|
||||
seen[mod.ModID] = true
|
||||
out = append(out, mod)
|
||||
}
|
||||
|
||||
for _, root := range roots {
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
content := filepath.Join(root, "steamapps", "workshop", "content", pzAppID)
|
||||
entries, err := os.ReadDir(content)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
workshopID := entry.Name()
|
||||
// Внутри пакета моды лежат в mods/<имя>/mod.info, но встречается
|
||||
// и раскладка без промежуточной папки mods.
|
||||
for _, modsDir := range []string{
|
||||
filepath.Join(content, workshopID, "mods"),
|
||||
filepath.Join(content, workshopID),
|
||||
} {
|
||||
for _, mod := range readModsDir(modsDir, workshopID) {
|
||||
add(mod)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, mod := range readModsDir(localMods, "") {
|
||||
add(mod)
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// readModsDir читает mod.info во всех поддиректориях dir.
|
||||
func readModsDir(dir, workshopID string) []InstalledMod {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []InstalledMod
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
modDir := filepath.Join(dir, entry.Name())
|
||||
mod, ok := readModInfo(filepath.Join(modDir, "mod.info"))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mod.WorkshopID = workshopID
|
||||
mod.Dir = modDir
|
||||
if mod.Name == "" {
|
||||
mod.Name = mod.ModID
|
||||
}
|
||||
out = append(out, mod)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readModInfo разбирает mod.info — файл вида "ключ=значение" с необязательными
|
||||
// комментариями. Нас интересуют id, name и description.
|
||||
func readModInfo(path string) (InstalledMod, bool) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return InstalledMod{}, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var mod InstalledMod
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 8*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, found := strings.Cut(line, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "id":
|
||||
mod.ModID = value
|
||||
case "name":
|
||||
mod.Name = value
|
||||
case "description":
|
||||
if len(value) > 300 {
|
||||
value = value[:300] + "…"
|
||||
}
|
||||
mod.Description = value
|
||||
}
|
||||
}
|
||||
return mod, mod.ModID != ""
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package pzconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestScanInstalledMods проверяет обе раскладки мастерской и локальную папку
|
||||
// модов, а также то, что дубликаты схлопываются.
|
||||
func TestScanInstalledMods(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
content := filepath.Join(root, "server", "steamapps", "workshop", "content", pzAppID)
|
||||
|
||||
// Обычная раскладка: <workshop id>/mods/<папка>/mod.info
|
||||
writeModInfo(t, filepath.Join(content, "111", "mods", "BetterSorting"),
|
||||
"name=Better Sorting\nid=BetterSorting\ndescription=Сортировка\n")
|
||||
// Второй мод в том же пакете мастерской.
|
||||
writeModInfo(t, filepath.Join(content, "111", "mods", "BetterSortingCore"),
|
||||
"name=Better Sorting Core\nid=BetterSortingCore\n")
|
||||
// Раскладка без промежуточной папки mods.
|
||||
writeModInfo(t, filepath.Join(content, "222", "ModOptions"),
|
||||
"name=Mod Options\nid=ModOptions\n")
|
||||
// Локальный мод, положенный руками.
|
||||
local := filepath.Join(root, "Zomboid", "mods")
|
||||
writeModInfo(t, filepath.Join(local, "MyTweak"), "name=My Tweak\nid=MyTweak\n")
|
||||
// Тот же мод, что и в мастерской: должен попасть в список один раз.
|
||||
writeModInfo(t, filepath.Join(local, "BetterSortingCopy"), "name=Better Sorting\nid=BetterSorting\n")
|
||||
// Папка без mod.info игнорируется.
|
||||
if err := os.MkdirAll(filepath.Join(content, "333", "mods", "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mods := ScanInstalledMods([]string{filepath.Join(root, "server")}, local)
|
||||
|
||||
byID := make(map[string]InstalledMod, len(mods))
|
||||
for _, m := range mods {
|
||||
if _, dup := byID[m.ModID]; dup {
|
||||
t.Errorf("мод %s встретился дважды", m.ModID)
|
||||
}
|
||||
byID[m.ModID] = m
|
||||
}
|
||||
if len(byID) != 4 {
|
||||
t.Fatalf("найдено %d модов, ожидалось 4: %v", len(byID), byID)
|
||||
}
|
||||
|
||||
if got := byID["BetterSorting"]; got.WorkshopID != "111" || got.Name != "Better Sorting" {
|
||||
t.Errorf("BetterSorting разобран неверно: %+v", got)
|
||||
}
|
||||
if got := byID["ModOptions"]; got.WorkshopID != "222" {
|
||||
t.Errorf("раскладка без папки mods не распознана: %+v", got)
|
||||
}
|
||||
if got := byID["MyTweak"]; got.WorkshopID != "" {
|
||||
t.Errorf("локальному моду проставлен Workshop ID: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadModInfoRequiresID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "mod.info")
|
||||
if err := os.WriteFile(path, []byte("name=Без идентификатора\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := readModInfo(path); ok {
|
||||
t.Error("мод без id принят")
|
||||
}
|
||||
}
|
||||
|
||||
func writeModInfo(t *testing.T, dir, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "mod.info"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,18 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// workshopAPI — публичный метод Steam, ключ доступа не требуется.
|
||||
const workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
|
||||
// Публичные методы Steam, ключ доступа не требуется.
|
||||
const (
|
||||
workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
|
||||
collectionAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/"
|
||||
)
|
||||
|
||||
// WorkshopItem — то, что панель показывает про мод из мастерской.
|
||||
type WorkshopItem struct {
|
||||
@@ -151,3 +155,71 @@ func (c *WorkshopClient) fetch(ctx context.Context, ids []string) ([]WorkshopIte
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Collection разворачивает коллекцию мастерской в список входящих в неё
|
||||
// модов. Порядок сохраняется тот же, что и на странице коллекции.
|
||||
func (c *WorkshopClient) Collection(ctx context.Context, id string) ([]string, error) {
|
||||
form := url.Values{}
|
||||
form.Set("collectioncount", "1")
|
||||
form.Set("publishedfileids[0]", id)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, collectionAPI,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Steam недоступен: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Steam ответил %s", resp.Status)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Response struct {
|
||||
CollectionDetails []struct {
|
||||
PublishedFileID string `json:"publishedfileid"`
|
||||
Result int `json:"result"`
|
||||
Children []struct {
|
||||
PublishedFileID string `json:"publishedfileid"`
|
||||
SortOrder int `json:"sortorder"`
|
||||
FileType int `json:"filetype"`
|
||||
} `json:"children"`
|
||||
} `json:"collectiondetails"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("ответ Steam не разобран: %w", err)
|
||||
}
|
||||
if len(payload.Response.CollectionDetails) == 0 {
|
||||
return nil, fmt.Errorf("коллекция %s не найдена", id)
|
||||
}
|
||||
|
||||
details := payload.Response.CollectionDetails[0]
|
||||
// result == 1 означает "найдено"; всё остальное — не коллекция, скрыта
|
||||
// или удалена.
|
||||
if details.Result != 1 {
|
||||
return nil, fmt.Errorf("коллекция %s недоступна — проверьте ссылку и что она открыта всем", id)
|
||||
}
|
||||
|
||||
children := details.Children
|
||||
sort.Slice(children, func(i, j int) bool { return children[i].SortOrder < children[j].SortOrder })
|
||||
|
||||
items := make([]string, 0, len(children))
|
||||
for _, child := range children {
|
||||
// filetype 0 — обычный элемент мастерской; вложенные коллекции
|
||||
// (filetype 2) пропускаем, иначе в список попадут их id вместо модов.
|
||||
if child.FileType != 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, child.PublishedFileID)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("в коллекции %s нет модов", id)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user