Files
mal/api/playback/handler.go

281 lines
7.1 KiB
Go

package playback
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"mal/integrations/jikan"
ctxpkg "mal/internal/context"
database "mal/internal/db"
"mal/templates"
)
type Handler struct {
svc *Service
jikanClient *jikan.Client
}
func NewHandler(svc *Service, jikanClient *jikan.Client) *Handler {
return &Handler{svc: svc, jikanClient: jikanClient}
}
func renderNotFoundPage(r *http.Request, w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound)
if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", map[string]any{
"CurrentPath": r.URL.Path,
}); err != nil {
log.Printf("render error: %v", err)
}
}
func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
// Path is like /anime/123/watch
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 {
renderNotFoundPage(r, w)
return
}
idStr := parts[2]
id, err := strconv.Atoi(idStr)
if err != nil {
renderNotFoundPage(r, w)
return
}
anime, err := h.jikanClient.GetAnimeByID(r.Context(), id)
if err != nil {
renderNotFoundPage(r, w)
return
}
// Try to get video episodes first (for thumbnails)
episodes, err := h.jikanClient.GetVideoEpisodes(r.Context(), id, 1)
if err != nil || len(episodes.Data) == 0 {
// Fallback to standard episodes if no video episodes
episodes, err = h.jikanClient.GetEpisodes(r.Context(), id, 1)
if err != nil {
log.Printf("watch error: %v", err)
}
}
var wg sync.WaitGroup
for i := range episodes.Data {
if episodes.Data[i].Images == nil {
episodes.Data[i].Images = &jikan.EpisodeImages{}
}
wg.Add(1)
go func(idx int) {
defer wg.Done()
episodes.Data[idx].Images.Jpg.ImageURL = episodes.Data[idx].GetFallbackImage(id)
}(i)
}
wg.Wait()
sort.Slice(episodes.Data, func(i, j int) bool {
return episodes.Data[i].MalID < episodes.Data[j].MalID
})
user, _ := r.Context().Value(ctxpkg.UserKey).(*database.User)
currentEpID := r.URL.Query().Get("ep")
if currentEpID == "" {
currentEpID = "1"
}
mode := r.URL.Query().Get("mode")
userID := ""
if user != nil {
userID = user.ID
}
titleCandidates := []string{anime.Title}
if anime.TitleEnglish != "" && anime.TitleEnglish != anime.Title {
titleCandidates = append(titleCandidates, anime.TitleEnglish)
}
if anime.TitleJapanese != "" {
titleCandidates = append(titleCandidates, anime.TitleJapanese)
}
watchData, err := h.svc.BuildWatchPageData(r.Context(), id, titleCandidates, currentEpID, mode, userID)
if err != nil {
log.Printf("watch data error: %v", err)
}
// Update episodes list if fallback has more
if watchData.FallbackEpisodes != nil {
maxCount := 0
for _, count := range watchData.FallbackEpisodes {
if count > maxCount {
maxCount = count
}
}
if maxCount > len(episodes.Data) {
// Fetch metadata for the missing episodes
start := len(episodes.Data) + 1
for i := start; i <= maxCount; i++ {
epStr := strconv.Itoa(i)
meta, err := h.svc.GetEpisodeMetadata(r.Context(), id, epStr)
title := fmt.Sprintf("Episode %d", i)
imgURL := ""
if err == nil && meta != nil {
if info, ok := meta["episodeInfo"].(map[string]any); ok {
if thumbs, ok := info["thumbnails"].([]any); ok && len(thumbs) > 0 {
if firstThumb, ok := thumbs[0].(string); ok {
imgURL = firstThumb
}
}
}
if notes, ok := meta["notes"].(string); ok && notes != "" {
title = notes
}
}
if imgURL == "" {
// Last resort fallback
tmpEp := jikan.Episode{MalID: i}
imgURL = tmpEp.GetFallbackImage(id)
}
episodes.Data = append(episodes.Data, jikan.Episode{
MalID: i,
Episode: fmt.Sprintf("Episode %d", i),
Title: title,
Images: &jikan.EpisodeImages{
Jpg: struct {
ImageURL string `json:"image_url"`
}{ImageURL: imgURL},
},
})
}
}
}
if err := templates.GetRenderer().ExecuteTemplate(w, "watch.gohtml", map[string]any{
"Anime": anime,
"Episodes": episodes.Data,
"WatchData": watchData,
"User": user,
"CurrentPath": r.URL.Path,
"CurrentEpID": currentEpID,
}); err != nil {
log.Printf("render error: %v", err)
}
}
func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "missing token", http.StatusBadRequest)
return
}
scope := proxyScopeStream
if strings.HasSuffix(r.URL.Path, "/segment") {
scope = proxyScopeSegment
} else if strings.HasSuffix(r.URL.Path, "/subtitle") {
scope = proxyScopeSubtitle
}
targetURL, referer, err := h.svc.resolveProxyToken(r.Context(), token, scope)
if err != nil {
http.Error(w, "invalid token", http.StatusForbidden)
return
}
rangeHeader := r.Header.Get("Range")
statusCode, headers, content, bodyReader, err := h.svc.ProxyStream(r.Context(), targetURL, referer, rangeHeader)
if err != nil {
log.Printf("proxy error for %s: %v", targetURL, err)
http.Error(w, "proxy failed", http.StatusBadGateway)
return
}
for k, v := range headers {
w.Header()[k] = v
}
w.WriteHeader(statusCode)
if bodyReader != nil {
defer bodyReader.Close()
_, _ = io.Copy(w, bodyReader)
} else {
_, _ = w.Write(content)
}
}
func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
func (h *Handler) HandleEpisodeData(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
// /api/watch/episode/{animeId}/{episodeId}
if len(parts) < 6 {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
animeID, err := strconv.Atoi(parts[4])
if err != nil {
http.Error(w, "invalid animeId", http.StatusBadRequest)
return
}
episodeID := parts[5]
user, _ := r.Context().Value(ctxpkg.UserKey).(*database.User)
userID := ""
if user != nil {
userID = user.ID
}
anime, err := h.jikanClient.GetAnimeByID(r.Context(), animeID)
if err != nil {
http.Error(w, "anime not found", http.StatusNotFound)
return
}
titleCandidates := []string{anime.Title}
if anime.TitleEnglish != "" && anime.TitleEnglish != anime.Title {
titleCandidates = append(titleCandidates, anime.TitleEnglish)
}
if anime.TitleJapanese != "" {
titleCandidates = append(titleCandidates, anime.TitleJapanese)
}
watchData, err := h.svc.BuildWatchPageData(r.Context(), animeID, titleCandidates, episodeID, "", userID)
if err != nil {
http.Error(w, "failed to build watch data", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"mal_id": watchData.MalID,
"title": watchData.Title,
"current_episode": watchData.CurrentEpisode,
"total_episodes": anime.Episodes,
"initial_mode": watchData.InitialMode,
"token": "", // The token might be per-source, wait, in Go it was per-mode?
"available_modes": watchData.AvailableModes,
"mode_sources": watchData.ModeSources,
"segments": watchData.Segments,
"episode_title": "", // Find episode title if possible
})
}