412 lines
11 KiB
Go
412 lines
11 KiB
Go
// Package handler provides the HTTP handler for playback endpoints.
|
|
package handler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"mal/internal/domain"
|
|
"mal/internal/server"
|
|
netutil "mal/pkg/net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type PlaybackHandler struct {
|
|
svc domain.PlaybackService
|
|
animeSvc domain.AnimePlaybackService
|
|
|
|
proxyClient *http.Client
|
|
streamingClient *http.Client
|
|
subtitleCache *subtitleCache
|
|
}
|
|
|
|
func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimePlaybackService) *PlaybackHandler {
|
|
return &PlaybackHandler{
|
|
svc: svc,
|
|
animeSvc: animeSvc,
|
|
proxyClient: netutil.NewClient(),
|
|
streamingClient: netutil.NewStreamingClient(),
|
|
subtitleCache: newSubtitleCache(10*time.Minute, 256),
|
|
}
|
|
}
|
|
|
|
func (h *PlaybackHandler) Register(r *gin.Engine) {
|
|
|
|
r.GET("/anime/:id/watch", h.HandleWatchPage)
|
|
r.POST("/api/watch-progress", h.HandleSaveProgress)
|
|
r.POST("/api/watch-complete", h.HandleWatchComplete)
|
|
r.GET("/api/watch/episode/:animeId/:episode", h.HandleEpisodeData)
|
|
r.POST("/api/watch/segments", h.HandleUpsertSkipSegment)
|
|
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) {
|
|
id, _ := strconv.Atoi(c.Param("id"))
|
|
ep := c.DefaultQuery("ep", "1")
|
|
mode := c.DefaultQuery("mode", "sub")
|
|
|
|
user := server.CurrentUser(c)
|
|
userID := server.CurrentUserID(c)
|
|
|
|
data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID)
|
|
if err != nil {
|
|
anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
|
|
c.HTML(http.StatusOK, "watch.gohtml", domain.WatchPageData{
|
|
Error: err.Error(),
|
|
Anime: anime,
|
|
Episodes: []domain.CanonicalEpisode{},
|
|
CurrentPath: c.Request.URL.Path,
|
|
User: user,
|
|
CurrentEpID: ep,
|
|
WatchData: domain.WatchData{
|
|
Episodes: []domain.CanonicalEpisode{},
|
|
Providers: []domain.ProviderData{},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
data.User = user
|
|
data.CurrentPath = c.Request.URL.Path
|
|
|
|
c.HTML(http.StatusOK, "watch.gohtml", data)
|
|
}
|
|
|
|
// HandleEpisodeData returns the minimal payload needed to advance to the next
|
|
// episode without a full page reload (preserves fullscreen).
|
|
func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
|
|
animeID, err := strconv.Atoi(c.Param("animeId"))
|
|
if err != nil || animeID <= 0 {
|
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
|
return
|
|
}
|
|
|
|
episode := c.Param("episode")
|
|
if episode == "" {
|
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "missing episode")
|
|
return
|
|
}
|
|
|
|
mode := c.DefaultQuery("mode", "sub")
|
|
|
|
userID := server.CurrentUserID(c)
|
|
|
|
data, err := h.svc.BuildWatchData(c.Request.Context(), animeID, []string{}, episode, mode, userID)
|
|
if err != nil {
|
|
server.RespondError(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
"watch_episode_data_build_failed",
|
|
"playback",
|
|
"failed to load episode data",
|
|
map[string]any{"anime_id": animeID, "episode": episode, "mode": mode, "user_id": userID},
|
|
err,
|
|
)
|
|
return
|
|
}
|
|
|
|
watchData := data.WatchData
|
|
|
|
// Try to resolve a title for this episode from the episode list.
|
|
episodeTitle := ""
|
|
epNum, _ := strconv.Atoi(episode)
|
|
for _, e := range watchData.Episodes {
|
|
if e.Number == epNum {
|
|
episodeTitle = e.Title
|
|
break
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"mode_sources": watchData.ModeSources,
|
|
"available_modes": watchData.AvailableModes,
|
|
"initial_mode": watchData.InitialMode,
|
|
"start_time_seconds": watchData.StartTimeSeconds,
|
|
"segments": watchData.Segments,
|
|
"episode_title": episodeTitle,
|
|
"mode_switched_from": watchData.ModeSwitchedFrom,
|
|
})
|
|
}
|
|
|
|
func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
|
|
userID := server.CurrentUserID(c)
|
|
if userID == "" {
|
|
// Avoid spamming 500s for anonymous playback; progress is user-scoped.
|
|
server.RespondHTMLOrJSONError(c, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
MalID int64 `json:"mal_id"`
|
|
Episode int `json:"episode"`
|
|
TimeSeconds float64 `json:"time_seconds"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
err := h.svc.SaveProgress(c.Request.Context(), userID, req.MalID, req.Episode, req.TimeSeconds)
|
|
if err != nil {
|
|
server.RespondError(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
"watch_progress_save_failed",
|
|
"playback",
|
|
"failed to save progress",
|
|
map[string]any{"mal_id": req.MalID, "episode": req.Episode, "user_id": userID},
|
|
err,
|
|
)
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
func (h *PlaybackHandler) HandleWatchComplete(c *gin.Context) {
|
|
userID := server.CurrentUserID(c)
|
|
if userID == "" {
|
|
server.RespondHTMLOrJSONError(c, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
MalID int64 `json:"mal_id"`
|
|
Episode int `json:"episode"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
err := h.svc.CompleteAnime(c.Request.Context(), userID, req.MalID)
|
|
if err != nil {
|
|
server.RespondError(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
"watch_complete_failed",
|
|
"playback",
|
|
"failed to complete anime",
|
|
map[string]any{"mal_id": req.MalID, "user_id": userID},
|
|
err,
|
|
)
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
func (h *PlaybackHandler) HandleUpsertSkipSegment(c *gin.Context) {
|
|
userID := server.CurrentUserID(c)
|
|
if userID == "" {
|
|
server.RespondHTMLOrJSONError(c, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
MalID int64 `json:"mal_id"`
|
|
Episode int `json:"episode"`
|
|
SkipType string `json:"skip_type"` // 'op' or 'ed' (also accepts 'opening'/'ending')
|
|
StartTime float64 `json:"start_time"`
|
|
EndTime float64 `json:"end_time"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
|
return
|
|
}
|
|
|
|
if err := h.svc.UpsertSkipSegmentOverride(c.Request.Context(), userID, req.MalID, req.Episode, req.SkipType, req.StartTime, req.EndTime); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
func (h *PlaybackHandler) HandleEpisodeThumbnails(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("animeId"))
|
|
if err != nil {
|
|
c.Status(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
allEpisodes, err := h.animeSvc.GetAllEpisodes(c.Request.Context(), id)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
|
|
if anime.Episodes > 0 && anime.Episodes > len(allEpisodes) {
|
|
epMap := make(map[int]domain.EpisodeData)
|
|
for _, ep := range allEpisodes {
|
|
epMap[ep.MalID] = ep
|
|
}
|
|
var filled []domain.EpisodeData
|
|
for i := 1; i <= anime.Episodes; i++ {
|
|
if ep, ok := epMap[i]; ok {
|
|
filled = append(filled, ep)
|
|
} else {
|
|
filled = append(filled, domain.EpisodeData{
|
|
MalID: i,
|
|
Title: fmt.Sprintf("Episode %d", i),
|
|
})
|
|
}
|
|
}
|
|
allEpisodes = filled
|
|
}
|
|
|
|
type Result struct {
|
|
MalID int `json:"mal_id"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
results := make([]Result, len(allEpisodes))
|
|
for i, ep := range allEpisodes {
|
|
results[i] = Result{
|
|
MalID: ep.MalID,
|
|
Title: ep.Title,
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, results)
|
|
}
|
|
|
|
func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
|
|
targetURL, referer, ok := h.resolveProxyRequestTarget(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
req, err := newProxyRequest(c.Request.Context(), targetURL, referer)
|
|
if err != nil {
|
|
c.Status(http.StatusBadGateway)
|
|
return
|
|
}
|
|
if rangeHeader := c.GetHeader("Range"); rangeHeader != "" {
|
|
req.Header.Set("Range", rangeHeader)
|
|
}
|
|
if ifRangeHeader := c.GetHeader("If-Range"); ifRangeHeader != "" {
|
|
req.Header.Set("If-Range", ifRangeHeader)
|
|
}
|
|
|
|
resp, err := h.streamingClient.Do(req)
|
|
if err != nil {
|
|
c.Status(http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
copyProxyHeaders(c.Writer.Header(), resp.Header)
|
|
c.Status(resp.StatusCode)
|
|
_, _ = io.Copy(c.Writer, resp.Body)
|
|
}
|
|
|
|
func copyProxyHeaders(dst http.Header, src http.Header) {
|
|
// Skip hop-by-hop headers; see RFC 7230 section 6.1.
|
|
// We intentionally preserve multi-value headers by copying the full slice.
|
|
for k, v := range src {
|
|
switch http.CanonicalHeaderKey(k) {
|
|
case "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade":
|
|
continue
|
|
}
|
|
// Copy the slice to avoid sharing memory with src.
|
|
copied := make([]string, len(v))
|
|
copy(copied, v)
|
|
dst[k] = copied
|
|
}
|
|
}
|
|
|
|
func (h *PlaybackHandler) resolveProxyRequestTarget(c *gin.Context) (string, string, bool) {
|
|
token := c.Query("token")
|
|
if token == "" {
|
|
c.Status(http.StatusBadRequest)
|
|
return "", "", false
|
|
}
|
|
|
|
targetURL, referer, err := h.svc.ResolveProxyToken(token)
|
|
if err != nil {
|
|
c.Status(http.StatusForbidden)
|
|
return "", "", false
|
|
}
|
|
|
|
return targetURL, referer, true
|
|
}
|
|
|
|
func newProxyRequest(ctx context.Context, targetURL string, referer string) (*http.Request, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if referer != "" {
|
|
req.Header.Set("Referer", referer)
|
|
}
|
|
req.Header.Set("User-Agent", netutil.Firefox121)
|
|
|
|
return req, nil
|
|
}
|
|
|
|
func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
|
|
targetURL, referer, ok := h.resolveProxyRequestTarget(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if data, contentType, ok := h.subtitleCache.Get(targetURL, time.Now()); ok {
|
|
c.Data(http.StatusOK, contentType, data)
|
|
return
|
|
}
|
|
|
|
req, err := newProxyRequest(c.Request.Context(), targetURL, referer)
|
|
if err != nil {
|
|
c.Status(http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
resp, err := h.proxyClient.Do(req)
|
|
if err != nil {
|
|
c.Status(http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
|
|
if err != nil {
|
|
c.Status(http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = detectSubtitleType(targetURL)
|
|
}
|
|
|
|
h.subtitleCache.Set(targetURL, body, contentType, time.Now())
|
|
|
|
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"
|
|
}
|
|
}
|