package pzconfig import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "sync" "time" ) // workshopAPI — публичный метод Steam, ключ доступа не требуется. const workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/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 }