feat: add lru+ttl subtitle cache
This commit is contained in:
@@ -5,11 +5,12 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"mal/pkg/net/proxytransport"
|
"mal/pkg/net/proxytransport"
|
||||||
|
"mal/pkg/net/useragent"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -20,7 +21,7 @@ type PlaybackHandler struct {
|
|||||||
|
|
||||||
proxyClient *http.Client
|
proxyClient *http.Client
|
||||||
streamingClient *http.Client
|
streamingClient *http.Client
|
||||||
subtitleCache sync.Map
|
subtitleCache *subtitleCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimeService) *PlaybackHandler {
|
func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimeService) *PlaybackHandler {
|
||||||
@@ -29,6 +30,7 @@ func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimeService
|
|||||||
animeSvc: animeSvc,
|
animeSvc: animeSvc,
|
||||||
proxyClient: proxytransport.NewClient(),
|
proxyClient: proxytransport.NewClient(),
|
||||||
streamingClient: proxytransport.NewStreamingClient(),
|
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 != "" {
|
if ifRangeHeader := c.GetHeader("If-Range"); ifRangeHeader != "" {
|
||||||
req.Header.Set("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)
|
resp, err := h.streamingClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -284,11 +286,6 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
|
|||||||
_, _ = io.Copy(c.Writer, resp.Body)
|
_, _ = io.Copy(c.Writer, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
type cachedSubtitle struct {
|
|
||||||
data []byte
|
|
||||||
contentType string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
|
func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
|
||||||
token := c.Query("token")
|
token := c.Query("token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -302,9 +299,8 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if cached, ok := h.subtitleCache.Load(targetURL); ok {
|
if data, contentType, ok := h.subtitleCache.Get(targetURL, time.Now()); ok {
|
||||||
entry := cached.(cachedSubtitle)
|
c.Data(http.StatusOK, contentType, data)
|
||||||
c.Data(http.StatusOK, entry.contentType, entry.data)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +312,7 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
|
|||||||
if referer != "" {
|
if referer != "" {
|
||||||
req.Header.Set("Referer", 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)
|
resp, err := h.proxyClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -336,7 +332,7 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
|
|||||||
contentType = detectSubtitleType(targetURL)
|
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)
|
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