Files
silo-server/internal/jellycompat/image_cache.go
QuickandGitHub 7c64c06e3d Redirect Jellyfin image routes to upstream URLs (#101)
* Redirect Jellyfin image routes to upstream URLs

* fix(jellycompat): proxy images for incompatible clients
2026-06-08 22:36:22 -04:00

166 lines
4.2 KiB
Go

package jellycompat
import (
"strings"
"sync"
"time"
)
const imageCacheExpirySafetyMargin = 5 * time.Minute
type cachedImage struct {
url string
expiresAt time.Time
}
// ImageCache keeps short-lived mappings from Jellyfin-style image requests to
// the underlying Silo image URLs.
type ImageCache struct {
mu sync.RWMutex
byTag map[string]cachedImage
byRoute map[string]cachedImage
ttl time.Duration
now func() time.Time
}
// NewImageCache creates a new cache for compat image lookups.
func NewImageCache(ttl time.Duration, now func() time.Time) *ImageCache {
if ttl <= 0 {
ttl = 24 * time.Hour
}
if now == nil {
now = time.Now
}
return &ImageCache{
byTag: make(map[string]cachedImage),
byRoute: make(map[string]cachedImage),
ttl: ttl,
now: now,
}
}
// Remember stores a Jellyfin image route mapping using the default compat size bucket.
func (c *ImageCache) Remember(routeID, imageType, imageURL string) {
c.RememberSized(routeID, imageType, imageURL, "")
}
// RememberSized stores a Jellyfin image route mapping for a specific size bucket.
func (c *ImageCache) RememberSized(routeID, imageType, imageURL, size string) {
c.RememberSizedUntil(routeID, imageType, imageURL, size, nil)
}
// RememberSizedUntil stores a Jellyfin image route mapping, capped by the
// underlying resolved URL expiry when that expiry is known.
func (c *ImageCache) RememberSizedUntil(routeID, imageType, imageURL, size string, urlExpiresAt *time.Time) {
if c == nil || routeID == "" || imageType == "" || imageURL == "" {
return
}
now := c.now()
expiresAt := now.Add(c.ttl)
if urlExpiresAt != nil {
capped := urlExpiresAt.Add(-imageCacheExpirySafetyMargin)
if !capped.After(now) {
return
}
if capped.Before(expiresAt) {
expiresAt = capped
}
}
entry := cachedImage{
url: imageURL,
expiresAt: expiresAt,
}
c.mu.Lock()
defer c.mu.Unlock()
if tag := tagValue(imageURL); tag != "" {
c.byTag[tag] = entry
}
c.byRoute[routeImageKey(routeID, imageType, size)] = entry
}
// Lookup returns a cached image URL by tag or route using the default compat size bucket.
func (c *ImageCache) Lookup(routeID, imageType, tag string) (string, bool) {
return c.LookupSized(routeID, imageType, tag, "")
}
// LookupSized returns a cached image URL by tag or route for a specific size bucket.
func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string, bool) {
if c == nil {
return "", false
}
if tag = canonicalCompatImageTag(tag); tag != "" {
return c.LookupTag(tag)
}
if routeID == "" || imageType == "" {
return "", false
}
return c.lookupRoute(routeImageKey(routeID, imageType, size))
}
// LookupTag resolves a cached image URL only by its legacy URL-derived tag.
func (c *ImageCache) LookupTag(tag string) (string, bool) {
if c == nil {
return "", false
}
return c.lookupTag(canonicalCompatImageTag(tag))
}
// lookupTag resolves a tag without size partitioning. Tags are sha1 of the
// presigned URL: for S3-cached paths the size variant is embedded in the URL
// (so different sizes produce different tags), and for HTTP-passthrough URLs
// the same URL is reused across sizes (so the cached entry must be reachable
// regardless of which size bucket the lookup asks about).
func (c *ImageCache) lookupTag(tag string) (string, bool) {
c.mu.RLock()
entry, ok := c.byTag[tag]
c.mu.RUnlock()
if !ok {
return "", false
}
if c.isExpired(entry) {
c.mu.Lock()
delete(c.byTag, tag)
c.mu.Unlock()
return "", false
}
return entry.url, true
}
func (c *ImageCache) lookupRoute(key string) (string, bool) {
c.mu.RLock()
entry, ok := c.byRoute[key]
c.mu.RUnlock()
if !ok {
return "", false
}
if c.isExpired(entry) {
c.mu.Lock()
delete(c.byRoute, key)
c.mu.Unlock()
return "", false
}
return entry.url, true
}
func (c *ImageCache) isExpired(entry cachedImage) bool {
return !entry.expiresAt.IsZero() && !entry.expiresAt.After(c.now())
}
func routeImageKey(routeID, imageType, size string) string {
return routeID + "|" + strings.ToLower(strings.TrimSpace(imageType)) + "|" + normalizeImageCacheSize(size)
}
func normalizeImageCacheSize(size string) string {
normalized := strings.ToLower(strings.TrimSpace(size))
if normalized == "" {
return compatCardImageSize
}
return normalized
}