feat: add lru+ttl subtitle cache

This commit is contained in:
2026-05-18 14:07:53 +02:00
parent 9859ddea42
commit d258d9af27
3 changed files with 150 additions and 13 deletions

View File

@@ -5,11 +5,12 @@ import (
"io"
"mal/internal/domain"
"mal/pkg/net/proxytransport"
"mal/pkg/net/useragent"
"maps"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
@@ -20,7 +21,7 @@ type PlaybackHandler struct {
proxyClient *http.Client
streamingClient *http.Client
subtitleCache sync.Map
subtitleCache *subtitleCache
}
func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimeService) *PlaybackHandler {
@@ -29,6 +30,7 @@ func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimeService
animeSvc: animeSvc,
proxyClient: proxytransport.NewClient(),
streamingClient: proxytransport.NewStreamingClient(),
subtitleCache: newSubtitleCache(10*time.Minute, 256),
}
}
@@ -268,7 +270,7 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
if ifRangeHeader := c.GetHeader("If-Range"); ifRangeHeader != "" {
req.Header.Set("If-Range", ifRangeHeader)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0")
req.Header.Set("User-Agent", useragent.Firefox121)
resp, err := h.streamingClient.Do(req)
if err != nil {
@@ -284,11 +286,6 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
_, _ = io.Copy(c.Writer, resp.Body)
}
type cachedSubtitle struct {
data []byte
contentType string
}
func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
token := c.Query("token")
if token == "" {
@@ -302,9 +299,8 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
return
}
if cached, ok := h.subtitleCache.Load(targetURL); ok {
entry := cached.(cachedSubtitle)
c.Data(http.StatusOK, entry.contentType, entry.data)
if data, contentType, ok := h.subtitleCache.Get(targetURL, time.Now()); ok {
c.Data(http.StatusOK, contentType, data)
return
}
@@ -316,7 +312,7 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
if referer != "" {
req.Header.Set("Referer", referer)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0")
req.Header.Set("User-Agent", useragent.Firefox121)
resp, err := h.proxyClient.Do(req)
if err != nil {
@@ -336,7 +332,7 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
contentType = detectSubtitleType(targetURL)
}
h.subtitleCache.Store(targetURL, cachedSubtitle{data: body, contentType: contentType})
h.subtitleCache.Set(targetURL, body, contentType, time.Now())
c.Data(http.StatusOK, contentType, body)
}

View File

@@ -0,0 +1,95 @@
package handler
import (
"container/list"
"sync"
"time"
)
type subtitleCacheEntry struct {
key string
data []byte
contentType string
expiresAt time.Time
}
// subtitleCache is a small TTL+LRU cache to avoid unbounded memory usage when
// proxying subtitles.
type subtitleCache struct {
mu sync.Mutex
ttl time.Duration
maxEntries int
entries map[string]*list.Element
lru *list.List
}
func newSubtitleCache(ttl time.Duration, maxEntries int) *subtitleCache {
if ttl <= 0 {
ttl = 10 * time.Minute
}
if maxEntries <= 0 {
maxEntries = 256
}
return &subtitleCache{
ttl: ttl,
maxEntries: maxEntries,
entries: make(map[string]*list.Element, maxEntries),
lru: list.New(),
}
}
func (c *subtitleCache) Get(key string, now time.Time) (data []byte, contentType string, ok bool) {
c.mu.Lock()
defer c.mu.Unlock()
el := c.entries[key]
if el == nil {
return nil, "", false
}
entry := el.Value.(subtitleCacheEntry)
if !entry.expiresAt.IsZero() && now.After(entry.expiresAt) {
c.removeElement(el)
return nil, "", false
}
c.lru.MoveToFront(el)
return entry.data, entry.contentType, true
}
func (c *subtitleCache) Set(key string, data []byte, contentType string, now time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
if el := c.entries[key]; el != nil {
entry := el.Value.(subtitleCacheEntry)
entry.data = data
entry.contentType = contentType
entry.expiresAt = now.Add(c.ttl)
el.Value = entry
c.lru.MoveToFront(el)
return
}
entry := subtitleCacheEntry{
key: key,
data: data,
contentType: contentType,
expiresAt: now.Add(c.ttl),
}
el := c.lru.PushFront(entry)
c.entries[key] = el
for len(c.entries) > c.maxEntries {
back := c.lru.Back()
if back == nil {
break
}
c.removeElement(back)
}
}
func (c *subtitleCache) removeElement(el *list.Element) {
entry := el.Value.(subtitleCacheEntry)
delete(c.entries, entry.key)
c.lru.Remove(el)
}

View File

@@ -0,0 +1,46 @@
package handler
import (
"testing"
"time"
)
func TestSubtitleCache_TTLExpiry(t *testing.T) {
c := newSubtitleCache(50*time.Millisecond, 10)
now := time.Unix(100, 0)
c.Set("k", []byte("v"), "text/plain", now)
if _, _, ok := c.Get("k", now.Add(49*time.Millisecond)); !ok {
t.Fatalf("expected cache hit before expiry")
}
if _, _, ok := c.Get("k", now.Add(51*time.Millisecond)); ok {
t.Fatalf("expected cache miss after expiry")
}
}
func TestSubtitleCache_LRUEviction(t *testing.T) {
c := newSubtitleCache(time.Minute, 2)
now := time.Unix(200, 0)
c.Set("a", []byte("a"), "text/plain", now)
c.Set("b", []byte("b"), "text/plain", now)
// Make "a" most recently used; "b" should be evicted next.
if _, _, ok := c.Get("a", now); !ok {
t.Fatalf("expected hit for a")
}
c.Set("c", []byte("c"), "text/plain", now)
if _, _, ok := c.Get("b", now); ok {
t.Fatalf("expected b to be evicted")
}
if _, _, ok := c.Get("a", now); !ok {
t.Fatalf("expected a to remain")
}
if _, _, ok := c.Get("c", now); !ok {
t.Fatalf("expected c to exist")
}
}