All checks were successful
continuous-integration/drone/push Build is passing
Step 1 of eliminating nextjs is adding a way to query thumbnails from roblox since nextjs handles that. This implements a batch endpoint and caching to do that. Bonus: thumbnails will actually work once we start using this. Reviewed-on: #285 Co-authored-by: itzaname <me@sliving.io> Co-committed-by: itzaname <me@sliving.io>
219 lines
6.0 KiB
Go
219 lines
6.0 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.itzana.me/strafesnet/maps-service/pkg/roblox"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
type ThumbnailService struct {
|
|
robloxClient *roblox.Client
|
|
redisClient *redis.Client
|
|
cacheTTL time.Duration
|
|
}
|
|
|
|
func NewThumbnailService(robloxClient *roblox.Client, redisClient *redis.Client) *ThumbnailService {
|
|
return &ThumbnailService{
|
|
robloxClient: robloxClient,
|
|
redisClient: redisClient,
|
|
cacheTTL: 24 * time.Hour, // Cache thumbnails for 24 hours
|
|
}
|
|
}
|
|
|
|
// CachedThumbnail represents a cached thumbnail entry
|
|
type CachedThumbnail struct {
|
|
ImageURL string `json:"imageUrl"`
|
|
State string `json:"state"`
|
|
CachedAt time.Time `json:"cachedAt"`
|
|
}
|
|
|
|
// GetAssetThumbnails fetches thumbnails with Redis caching and batching
|
|
func (s *ThumbnailService) GetAssetThumbnails(ctx context.Context, assetIDs []uint64, size roblox.ThumbnailSize) (map[uint64]string, error) {
|
|
if len(assetIDs) == 0 {
|
|
return map[uint64]string{}, nil
|
|
}
|
|
|
|
result := make(map[uint64]string)
|
|
var missingIDs []uint64
|
|
|
|
// Try to get from cache first
|
|
for _, assetID := range assetIDs {
|
|
cacheKey := fmt.Sprintf("thumbnail:asset:%d:%s", assetID, size)
|
|
cached, err := s.redisClient.Get(ctx, cacheKey).Result()
|
|
|
|
if err == redis.Nil {
|
|
// Cache miss
|
|
missingIDs = append(missingIDs, assetID)
|
|
} else if err != nil {
|
|
// Redis error - treat as cache miss
|
|
missingIDs = append(missingIDs, assetID)
|
|
} else {
|
|
// Cache hit
|
|
var thumbnail CachedThumbnail
|
|
if err := json.Unmarshal([]byte(cached), &thumbnail); err == nil && thumbnail.State == "Completed" {
|
|
result[assetID] = thumbnail.ImageURL
|
|
} else {
|
|
missingIDs = append(missingIDs, assetID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// If all were cached, return early
|
|
if len(missingIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
// Batch fetch missing thumbnails from Roblox API
|
|
// Split into batches of 100 (Roblox API limit)
|
|
for i := 0; i < len(missingIDs); i += 100 {
|
|
end := i + 100
|
|
if end > len(missingIDs) {
|
|
end = len(missingIDs)
|
|
}
|
|
batch := missingIDs[i:end]
|
|
|
|
thumbnails, err := s.robloxClient.GetAssetThumbnails(batch, size, roblox.FormatPng)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch thumbnails: %w", err)
|
|
}
|
|
|
|
// Process results and cache them
|
|
for _, thumb := range thumbnails {
|
|
cached := CachedThumbnail{
|
|
ImageURL: thumb.ImageURL,
|
|
State: thumb.State,
|
|
CachedAt: time.Now(),
|
|
}
|
|
|
|
if thumb.State == "Completed" && thumb.ImageURL != "" {
|
|
result[thumb.TargetID] = thumb.ImageURL
|
|
}
|
|
|
|
// Cache the result (even if incomplete, to avoid repeated API calls)
|
|
cacheKey := fmt.Sprintf("thumbnail:asset:%d:%s", thumb.TargetID, size)
|
|
cachedJSON, _ := json.Marshal(cached)
|
|
|
|
// Use shorter TTL for incomplete thumbnails
|
|
ttl := s.cacheTTL
|
|
if thumb.State != "Completed" {
|
|
ttl = 5 * time.Minute
|
|
}
|
|
|
|
s.redisClient.Set(ctx, cacheKey, cachedJSON, ttl)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetUserAvatarThumbnails fetches user avatar thumbnails with Redis caching and batching
|
|
func (s *ThumbnailService) GetUserAvatarThumbnails(ctx context.Context, userIDs []uint64, size roblox.ThumbnailSize) (map[uint64]string, error) {
|
|
if len(userIDs) == 0 {
|
|
return map[uint64]string{}, nil
|
|
}
|
|
|
|
result := make(map[uint64]string)
|
|
var missingIDs []uint64
|
|
|
|
// Try to get from cache first
|
|
for _, userID := range userIDs {
|
|
cacheKey := fmt.Sprintf("thumbnail:user:%d:%s", userID, size)
|
|
cached, err := s.redisClient.Get(ctx, cacheKey).Result()
|
|
|
|
if err == redis.Nil {
|
|
// Cache miss
|
|
missingIDs = append(missingIDs, userID)
|
|
} else if err != nil {
|
|
// Redis error - treat as cache miss
|
|
missingIDs = append(missingIDs, userID)
|
|
} else {
|
|
// Cache hit
|
|
var thumbnail CachedThumbnail
|
|
if err := json.Unmarshal([]byte(cached), &thumbnail); err == nil && thumbnail.State == "Completed" {
|
|
result[userID] = thumbnail.ImageURL
|
|
} else {
|
|
missingIDs = append(missingIDs, userID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// If all were cached, return early
|
|
if len(missingIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
// Batch fetch missing thumbnails from Roblox API
|
|
// Split into batches of 100 (Roblox API limit)
|
|
for i := 0; i < len(missingIDs); i += 100 {
|
|
end := i + 100
|
|
if end > len(missingIDs) {
|
|
end = len(missingIDs)
|
|
}
|
|
batch := missingIDs[i:end]
|
|
|
|
thumbnails, err := s.robloxClient.GetUserAvatarThumbnails(batch, size, roblox.FormatPng)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch user thumbnails: %w", err)
|
|
}
|
|
|
|
// Process results and cache them
|
|
for _, thumb := range thumbnails {
|
|
cached := CachedThumbnail{
|
|
ImageURL: thumb.ImageURL,
|
|
State: thumb.State,
|
|
CachedAt: time.Now(),
|
|
}
|
|
|
|
if thumb.State == "Completed" && thumb.ImageURL != "" {
|
|
result[thumb.TargetID] = thumb.ImageURL
|
|
}
|
|
|
|
// Cache the result
|
|
cacheKey := fmt.Sprintf("thumbnail:user:%d:%s", thumb.TargetID, size)
|
|
cachedJSON, _ := json.Marshal(cached)
|
|
|
|
// Use shorter TTL for incomplete thumbnails
|
|
ttl := s.cacheTTL
|
|
if thumb.State != "Completed" {
|
|
ttl = 5 * time.Minute
|
|
}
|
|
|
|
s.redisClient.Set(ctx, cacheKey, cachedJSON, ttl)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetSingleAssetThumbnail is a convenience method for fetching a single asset thumbnail
|
|
func (s *ThumbnailService) GetSingleAssetThumbnail(ctx context.Context, assetID uint64, size roblox.ThumbnailSize) (string, error) {
|
|
results, err := s.GetAssetThumbnails(ctx, []uint64{assetID}, size)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if url, ok := results[assetID]; ok {
|
|
return url, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("thumbnail not available for asset %d", assetID)
|
|
}
|
|
|
|
// GetSingleUserAvatarThumbnail is a convenience method for fetching a single user avatar thumbnail
|
|
func (s *ThumbnailService) GetSingleUserAvatarThumbnail(ctx context.Context, userID uint64, size roblox.ThumbnailSize) (string, error) {
|
|
results, err := s.GetUserAvatarThumbnails(ctx, []uint64{userID}, size)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if url, ok := results[userID]; ok {
|
|
return url, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("thumbnail not available for user %d", userID)
|
|
}
|