Мод-менеджер с галочками и коллекции мастерской

Вместо двух отдельных списков (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:
2026-09-04 00:36:06 +03:00
co-authored by Claude Opus 5
parent 277ab7af4a
commit fe517cfb71
9 changed files with 675 additions and 71 deletions
+74 -2
View File
@@ -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
}