feat: add proxy transport with dns caching, subtitle handler, and stream pre-warm

This commit is contained in:
2026-05-13 13:35:27 +02:00
parent 8006868fe2
commit 846fe9ccd9
3 changed files with 202 additions and 5 deletions

View File

@@ -5,8 +5,11 @@ import (
"io"
"log"
"mal/internal/domain"
"mal/pkg/net/proxytransport"
"net/http"
"strconv"
"strings"
"sync"
"github.com/gin-gonic/gin"
)
@@ -14,10 +17,19 @@ import (
type PlaybackHandler struct {
svc domain.PlaybackService
animeSvc domain.AnimeService
proxyClient *http.Client
streamingClient *http.Client
subtitleCache sync.Map
}
func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimeService) *PlaybackHandler {
return &PlaybackHandler{svc: svc, animeSvc: animeSvc}
return &PlaybackHandler{
svc: svc,
animeSvc: animeSvc,
proxyClient: proxytransport.NewClient(),
streamingClient: proxytransport.NewStreamingClient(),
}
}
func (h *PlaybackHandler) Register(r *gin.Engine) {
@@ -26,6 +38,7 @@ func (h *PlaybackHandler) Register(r *gin.Engine) {
r.POST("/api/watch-progress", h.HandleSaveProgress)
r.GET("/api/watch/thumbnails/:animeId", h.HandleEpisodeThumbnails)
r.GET("/watch/proxy/stream", h.HandleProxyStream)
r.GET("/watch/proxy/subtitle", h.HandleProxySubtitle)
}
func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
@@ -170,9 +183,9 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0")
resp, err := http.DefaultClient.Do(req)
resp, err := h.streamingClient.Do(req)
if err != nil {
log.Printf("proxy fetch error: %v", err)
log.Printf("proxy stream fetch error: %v", err)
c.Status(http.StatusBadGateway)
return
}
@@ -184,3 +197,77 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
c.Status(resp.StatusCode)
_, _ = 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 == "" {
c.Status(http.StatusBadRequest)
return
}
targetURL, referer, err := h.svc.ResolveProxyToken(token)
if err != nil {
log.Printf("proxy subtitle token error: %v", err)
c.Status(http.StatusForbidden)
return
}
if cached, ok := h.subtitleCache.Load(targetURL); ok {
entry := cached.(cachedSubtitle)
c.Data(http.StatusOK, entry.contentType, entry.data)
return
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, targetURL, nil)
if err != nil {
c.Status(http.StatusBadGateway)
return
}
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")
resp, err := h.proxyClient.Do(req)
if err != nil {
log.Printf("proxy subtitle fetch error: %v", err)
c.Status(http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if err != nil {
log.Printf("proxy subtitle read error: %v", err)
c.Status(http.StatusBadGateway)
return
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = detectSubtitleType(targetURL)
}
h.subtitleCache.Store(targetURL, cachedSubtitle{data: body, contentType: contentType})
c.Data(http.StatusOK, contentType, body)
}
func detectSubtitleType(url string) string {
lower := strings.ToLower(url)
switch {
case strings.Contains(lower, ".vtt"):
return "text/vtt"
case strings.Contains(lower, ".srt"):
return "text/plain; charset=utf-8"
case strings.Contains(lower, ".ass") || strings.Contains(lower, ".ssa"):
return "text/plain; charset=utf-8"
default:
return "text/plain; charset=utf-8"
}
}

View File

@@ -222,9 +222,10 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
var subtitleItems []SubtitleItem
for _, sub := range result.Subtitles {
subToken, _ := s.SignProxyToken(sub.URL, result.Referer, "subtitle")
subtitleItems = append(subtitleItems, SubtitleItem{
Lang: sub.Label,
URL: sub.URL,
Lang: sub.Label,
Token: subToken,
})
}
@@ -239,6 +240,8 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
},
}
go s.warmStreamURL(result.URL, result.Referer)
// 6. Resolve relations/seasons
relations, _ := s.jikan.GetFullRelations(ctx, animeID)
type SeasonEntry struct {
@@ -368,3 +371,24 @@ func (s *playbackService) fetchSkipSegments(ctx context.Context, malID int, epis
return segments
}
func (s *playbackService) warmStreamURL(targetURL, referer string) {
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
if err != nil {
return
}
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")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req = req.WithContext(ctx)
resp, err := s.httpClient.Do(req)
if err != nil {
return
}
_ = resp.Body.Close()
}