* Redirect Jellyfin image routes to upstream URLs * fix(jellycompat): proxy images for incompatible clients
166 lines
4.2 KiB
Go
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
|
|
}
|