Files
pzmanager/internal/pzconfig/workshop.go
T
mrleo1nidandClaude Opus 5 fe517cfb71 Мод-менеджер с галочками и коллекции мастерской
Вместо двух отдельных списков (Workshop ID и Mod ID) — один список модов:
галочка включает мод, перетаскивание задаёт порядок загрузки, кнопка
убирает пакет вместе со всеми его модами.

Чтобы галочкам было за что зацепиться, панель читает mod.info уже
скачанных модов: серверу в Mods нужен именно Mod ID, а он лежит внутри
пакета мастерской и вручную его приходилось искать в описании мода. Пока
мод не скачан, строка помечена и галочка недоступна — Mod ID неизвестен.

Один пакет мастерской может содержать несколько модов; теперь они видны
отдельными строками и включаются по отдельности.

Коллекции: кнопка разворачивает коллекцию Steam в список входящих в неё
модов, сохраняя порядок со страницы коллекции. И мод, и коллекция
принимаются как ссылкой, так и голым идентификатором.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:36:06 +03:00

226 lines
7.3 KiB
Go

package pzconfig
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Публичные методы Steam, ключ доступа не требуется.
const (
workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
collectionAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/"
)
// WorkshopItem — то, что панель показывает про мод из мастерской.
type WorkshopItem struct {
ID string `json:"id"`
Title string `json:"title"`
Preview string `json:"preview"`
Description string `json:"description"`
Error string `json:"error,omitempty"`
}
// WorkshopClient запрашивает названия модов у Steam и кеширует их: список
// модов на странице обновляется часто, а названия не меняются.
type WorkshopClient struct {
HTTP *http.Client
mu sync.Mutex
cache map[string]cachedItem
}
type cachedItem struct {
item WorkshopItem
fetched time.Time
}
// cacheTTL — как долго доверять закешированному названию мода.
const cacheTTL = 12 * time.Hour
// NewWorkshopClient создаёт клиент со своим HTTP-таймаутом.
func NewWorkshopClient() *WorkshopClient {
return &WorkshopClient{
HTTP: &http.Client{Timeout: 15 * time.Second},
cache: make(map[string]cachedItem),
}
}
// Details запрашивает описание модов по их Workshop ID. Если Steam недоступен,
// возвращает записи с заполненным полем Error — панель всё равно покажет
// список, просто без названий.
func (c *WorkshopClient) Details(ctx context.Context, ids []string) []WorkshopItem {
out := make([]WorkshopItem, 0, len(ids))
missing := make([]string, 0, len(ids))
c.mu.Lock()
for _, id := range ids {
if entry, ok := c.cache[id]; ok && time.Since(entry.fetched) < cacheTTL {
out = append(out, entry.item)
continue
}
missing = append(missing, id)
}
c.mu.Unlock()
if len(missing) > 0 {
fetched, err := c.fetch(ctx, missing)
if err != nil {
for _, id := range missing {
out = append(out, WorkshopItem{ID: id, Error: err.Error()})
}
} else {
c.mu.Lock()
for _, item := range fetched {
c.cache[item.ID] = cachedItem{item: item, fetched: time.Now()}
}
c.mu.Unlock()
out = append(out, fetched...)
}
}
// Возвращаем в том же порядке, в каком просили.
byID := make(map[string]WorkshopItem, len(out))
for _, item := range out {
byID[item.ID] = item
}
ordered := make([]WorkshopItem, 0, len(ids))
for _, id := range ids {
if item, ok := byID[id]; ok {
ordered = append(ordered, item)
continue
}
ordered = append(ordered, WorkshopItem{ID: id, Error: "нет данных"})
}
return ordered
}
func (c *WorkshopClient) fetch(ctx context.Context, ids []string) ([]WorkshopItem, error) {
form := url.Values{}
form.Set("itemcount", strconv.Itoa(len(ids)))
for n, id := range ids {
form.Set(fmt.Sprintf("publishedfileids[%d]", n), id)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, workshopAPI,
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 {
PublishedFileDetails []struct {
PublishedFileID string `json:"publishedfileid"`
Title string `json:"title"`
Description string `json:"description"`
PreviewURL string `json:"preview_url"`
Result int `json:"result"`
} `json:"publishedfiledetails"`
} `json:"response"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("ответ Steam не разобран: %w", err)
}
items := make([]WorkshopItem, 0, len(payload.Response.PublishedFileDetails))
for _, d := range payload.Response.PublishedFileDetails {
item := WorkshopItem{ID: d.PublishedFileID, Title: d.Title, Preview: d.PreviewURL}
// result == 1 означает "найдено"; всё остальное — удалён или скрыт.
if d.Result != 1 {
item.Error = "мод не найден в мастерской"
}
if len(d.Description) > 400 {
d.Description = d.Description[:400] + "…"
}
item.Description = d.Description
items = append(items, item)
}
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
}