diff --git a/internal/playback/handler/handler.go b/internal/playback/handler/handler.go index 79ace8a..94a33fa 100644 --- a/internal/playback/handler/handler.go +++ b/internal/playback/handler/handler.go @@ -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) } diff --git a/internal/playback/handler/subtitle_cache.go b/internal/playback/handler/subtitle_cache.go new file mode 100644 index 0000000..7cf0d97 --- /dev/null +++ b/internal/playback/handler/subtitle_cache.go @@ -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) +} diff --git a/internal/playback/handler/subtitle_cache_test.go b/internal/playback/handler/subtitle_cache_test.go new file mode 100644 index 0000000..eee3dcd --- /dev/null +++ b/internal/playback/handler/subtitle_cache_test.go @@ -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") + } +} +