Вместо двух отдельных списков (Workshop ID и Mod ID) — один список модов: галочка включает мод, перетаскивание задаёт порядок загрузки, кнопка убирает пакет вместе со всеми его модами. Чтобы галочкам было за что зацепиться, панель читает mod.info уже скачанных модов: серверу в Mods нужен именно Mod ID, а он лежит внутри пакета мастерской и вручную его приходилось искать в описании мода. Пока мод не скачан, строка помечена и галочка недоступна — Mod ID неизвестен. Один пакет мастерской может содержать несколько модов; теперь они видны отдельными строками и включаются по отдельности. Коллекции: кнопка разворачивает коллекцию Steam в список входящих в неё модов, сохраняя порядок со страницы коллекции. И мод, и коллекция принимаются как ссылкой, так и голым идентификатором. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
226 lines
7.3 KiB
Go
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
|
|
}
|