feat: add lru+ttl subtitle cache
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
95
internal/playback/handler/subtitle_cache.go
Normal file
95
internal/playback/handler/subtitle_cache.go
Normal 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)
|
||||
}
|
||||
46
internal/playback/handler/subtitle_cache_test.go
Normal file
46
internal/playback/handler/subtitle_cache_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user