refactor: migrate from templ to html/template
This commit is contained in:
@@ -1,35 +1,17 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
animecomponents "mal/web/components/anime"
|
||||
watchcomponents "mal/web/components/watch"
|
||||
webcontext "mal/web/context"
|
||||
"mal/web/templates"
|
||||
"mal/templates"
|
||||
)
|
||||
|
||||
func deduplicateAnimes(animes []jikan.Anime) []jikan.Anime {
|
||||
seen := make(map[int]bool)
|
||||
var result []jikan.Anime
|
||||
for _, a := range animes {
|
||||
if !seen[a.MalID] {
|
||||
seen[a.MalID] = true
|
||||
result = append(result, a)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
jikanClient *jikan.Client
|
||||
db database.Querier
|
||||
@@ -44,7 +26,7 @@ type quickSearchResult struct {
|
||||
|
||||
func renderNotFoundPage(r *http.Request, w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
if err := templates.NotFoundPage().Render(r.Context(), w); err != nil {
|
||||
if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", nil); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -60,19 +42,9 @@ func parsePageParam(r *http.Request) int {
|
||||
if page < 1 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
func userIDFromRequest(r *http.Request) string {
|
||||
user, ok := r.Context().Value(webcontext.UserKey).(*database.User)
|
||||
if !ok || user == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return user.ID
|
||||
}
|
||||
|
||||
func NewHandler(jikanClient *jikan.Client, db database.Querier) *Handler {
|
||||
return &Handler{jikanClient: jikanClient, db: db}
|
||||
}
|
||||
@@ -82,312 +54,67 @@ func (h *Handler) HandleCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
renderNotFoundPage(r, w)
|
||||
return
|
||||
}
|
||||
if err := templates.Catalog().Render(r.Context(), w); err != nil {
|
||||
|
||||
animes, err := h.jikanClient.GetTopAnime(r.Context(), 1)
|
||||
if err != nil {
|
||||
log.Printf("top anime error: %v", err)
|
||||
http.Error(w, "Failed to fetch anime", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(animes.Animes) > 4 {
|
||||
animes.Animes = animes.Animes[:4]
|
||||
}
|
||||
|
||||
if err := templates.GetRenderer().ExecuteTemplate(w, "index.gohtml", map[string]any{
|
||||
"Animes": animes.Animes,
|
||||
}); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) watchlistMap(ctx context.Context, userID string) map[int]string {
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
entries, err := h.db.GetUserWatchList(ctx, userID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
m := make(map[int]string, len(entries))
|
||||
for _, e := range entries {
|
||||
m[int(e.AnimeID)] = e.Status
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (h *Handler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Vary", "HX-Request")
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
if err := templates.Search("").Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
res, err := h.jikanClient.Search(r.Context(), query, 1)
|
||||
if err != nil {
|
||||
log.Printf("search error: %v", err)
|
||||
if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) {
|
||||
writeInlineLoadError(w, "Search is temporarily unavailable. Please retry in a few seconds.")
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to search anime", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
|
||||
if err := templates.SearchResultsWrapper(query, res.Animes, statuses, 2, res.HasNextPage).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := templates.Search(query).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
renderNotFoundPage(r, w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPISearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
page := parsePageParam(r)
|
||||
|
||||
res, err := h.jikanClient.Search(r.Context(), query, page)
|
||||
if err != nil {
|
||||
log.Printf("search pagination error: %v", err)
|
||||
if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) {
|
||||
writeInlineLoadError(w, "Unable to load more results right now. Please retry shortly.")
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to fetch search page", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res.Animes = deduplicateAnimes(res.Animes)
|
||||
|
||||
statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
|
||||
if err := templates.SearchItems(query, res.Animes, statuses, page+1, res.HasNextPage).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
http.Error(w, "Not implemented yet", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPICatalog(w http.ResponseWriter, r *http.Request) {
|
||||
page := parsePageParam(r)
|
||||
|
||||
result, err := h.jikanClient.GetTopAnime(r.Context(), page)
|
||||
if err == nil {
|
||||
result.Animes = deduplicateAnimes(result.Animes)
|
||||
statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
|
||||
if err := templates.CatalogItems(result.Animes, statuses, page+1, result.HasNextPage).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if jikan.IsRetryableError(err) {
|
||||
if err := templates.CatalogError("Unable to load anime catalog").Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("top anime error: %v", err)
|
||||
http.Error(w, "Failed to fetch top anime", http.StatusInternalServerError)
|
||||
http.Error(w, "Not implemented yet", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.URL.Path[len("/anime/"):]
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id <= 0 {
|
||||
renderNotFoundPage(r, w)
|
||||
return
|
||||
}
|
||||
|
||||
userID := userIDFromRequest(r)
|
||||
|
||||
anime, err := h.jikanClient.GetAnimeByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if jikan.IsNotFoundError(err) {
|
||||
renderNotFoundPage(r, w)
|
||||
return
|
||||
}
|
||||
|
||||
h.jikanClient.EnqueueAnimeFetchRetry(r.Context(), id, err)
|
||||
if jikan.IsRetryableError(err) {
|
||||
if err := animecomponents.Pending(id).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("anime fetch error for %d: %v", id, err)
|
||||
http.Error(w, "Failed to fetch anime details", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
currentStatus := ""
|
||||
nextEpisode := 1
|
||||
if userID != "" {
|
||||
entry, err := h.db.GetWatchListEntry(r.Context(), database.GetWatchListEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: int64(id),
|
||||
})
|
||||
if err == nil {
|
||||
currentStatus = entry.Status
|
||||
if entry.CurrentEpisode.Valid {
|
||||
value := int(entry.CurrentEpisode.Int64)
|
||||
if value > 0 {
|
||||
nextEpisode = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := templates.AnimeDetails(anime, currentStatus, nextEpisode).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
renderNotFoundPage(r, w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path[len("/api/anime/"):]
|
||||
|
||||
idPart, section, ok := strings.Cut(path, "/")
|
||||
if !ok || section == "" {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(idPart)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
|
||||
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
|
||||
switch section {
|
||||
case "relations":
|
||||
relations, err := h.jikanClient.GetFullRelations(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
log.Printf("relations error for %d: %v", id, err)
|
||||
writeInlineLoadError(w, "Failed to load relations.")
|
||||
return
|
||||
}
|
||||
if err := animecomponents.RelationsList(relations, statuses).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case "recommendations":
|
||||
recs, err := h.jikanClient.GetRecommendations(r.Context(), id, 12)
|
||||
if err != nil {
|
||||
log.Printf("recommendations error for %d: %v", id, err)
|
||||
writeInlineLoadError(w, "Failed to load recommendations.")
|
||||
return
|
||||
}
|
||||
if err := animecomponents.Recommendations(recs, statuses).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case "episodes":
|
||||
currentEpisode := r.URL.Query().Get("current")
|
||||
episodes, err := h.getEpisodes(r.Context(), id)
|
||||
if err != nil {
|
||||
log.Printf("episodes error for %d: %v", id, err)
|
||||
writeInlineLoadError(w, "Failed to load episodes.")
|
||||
return
|
||||
}
|
||||
if err := watchcomponents.EpisodeList(episodes, currentEpisode, id).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
default:
|
||||
renderNotFoundPage(r, w)
|
||||
}
|
||||
renderNotFoundPage(r, w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPIEpisodes(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path[len("/api/episodes/"):]
|
||||
id, err := strconv.Atoi(path)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
|
||||
currentEpisode := r.URL.Query().Get("current")
|
||||
episodes, err := h.getEpisodes(r.Context(), id)
|
||||
if err != nil {
|
||||
log.Printf("episodes error: %v", err)
|
||||
writeInlineLoadError(w, "Failed to load episodes.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := watchcomponents.EpisodeList(episodes, currentEpisode, id).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) getEpisodes(ctx context.Context, animeID int) ([]jikan.Episode, error) {
|
||||
var allEpisodes []jikan.Episode
|
||||
page := 1
|
||||
|
||||
for page <= 20 {
|
||||
result, err := h.jikanClient.GetEpisodes(ctx, animeID, page)
|
||||
if err != nil {
|
||||
if jikan.IsRetryableError(err) && len(allEpisodes) > 0 {
|
||||
return allEpisodes, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allEpisodes = append(allEpisodes, result.Data...)
|
||||
|
||||
if !result.Pagination.HasNextPage {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return allEpisodes, nil
|
||||
http.Error(w, "Not implemented yet", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode([]quickSearchResult{})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.jikanClient.SearchWithLimit(r.Context(), query, 1, 5)
|
||||
if err != nil {
|
||||
log.Printf("quick search error: %v", err)
|
||||
if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode([]quickSearchResult{})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode([]quickSearchResult{})
|
||||
return
|
||||
}
|
||||
|
||||
results := res.Animes
|
||||
|
||||
output := make([]quickSearchResult, len(results))
|
||||
for i, anime := range results {
|
||||
output := make([]quickSearchResult, len(res.Animes))
|
||||
for i, anime := range res.Animes {
|
||||
output[i] = quickSearchResult{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
@@ -395,132 +122,26 @@ func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) {
|
||||
Image: anime.ImageURL(),
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(output)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
if err := templates.Discover().Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
renderNotFoundPage(r, w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPIDiscoverAiring(w http.ResponseWriter, r *http.Request) {
|
||||
page := parsePageParam(r)
|
||||
|
||||
res, err := h.jikanClient.GetSeasonsNow(r.Context(), page)
|
||||
if err != nil {
|
||||
log.Printf("airing anime error: %v", err)
|
||||
http.Error(w, "Failed to fetch airing anime", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res.Animes = deduplicateAnimes(res.Animes)
|
||||
|
||||
statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
|
||||
if err := templates.DiscoverItems(res.Animes, statuses, "airing", page+1, res.HasNextPage).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
http.Error(w, "Not implemented yet", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPIDiscoverUpcoming(w http.ResponseWriter, r *http.Request) {
|
||||
page := parsePageParam(r)
|
||||
|
||||
res, err := h.jikanClient.GetSeasonsUpcoming(r.Context(), page)
|
||||
if err != nil {
|
||||
log.Printf("upcoming anime error: %v", err)
|
||||
http.Error(w, "Failed to fetch upcoming anime", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res.Animes = deduplicateAnimes(res.Animes)
|
||||
|
||||
statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
|
||||
if err := templates.DiscoverItems(res.Animes, statuses, "upcoming", page+1, res.HasNextPage).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
http.Error(w, "Not implemented yet", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleStudioDetails(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.URL.Path[len("/studios/"):]
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id <= 0 {
|
||||
renderNotFoundPage(r, w)
|
||||
return
|
||||
}
|
||||
|
||||
producer, err := h.jikanClient.GetProducerByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if jikan.IsNotFoundError(err) {
|
||||
renderNotFoundPage(r, w)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("studio fetch error for %d: %v", id, err)
|
||||
http.Error(w, "Failed to fetch studio details", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.jikanClient.GetAnimeByProducer(r.Context(), id, 1)
|
||||
if err != nil {
|
||||
log.Printf("studio anime fetch error for %d: %v", id, err)
|
||||
if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) {
|
||||
// Render page with empty anime list if API is rate limiting
|
||||
if err := templates.StudioDetails(producer, []jikan.Anime{}, nil, false, 2).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to fetch studio anime", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
|
||||
if err := templates.StudioDetails(producer, result.Animes, statuses, result.HasNextPage, 2).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
renderNotFoundPage(r, w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPIStudioAnime(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path[len("/api/studios/"):]
|
||||
|
||||
idPart, after, ok := strings.Cut(path, "/")
|
||||
if !ok || after != "anime" {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(idPart)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := parsePageParam(r)
|
||||
|
||||
result, err := h.jikanClient.GetAnimeByProducer(r.Context(), id, page)
|
||||
if err != nil {
|
||||
log.Printf("studio anime pagination error for %d page %d: %v", id, page, err)
|
||||
if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) {
|
||||
writeInlineLoadError(w, "Unable to load more results right now. Please retry shortly.")
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to fetch studio anime", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result.Animes = deduplicateAnimes(result.Animes)
|
||||
|
||||
statuses := h.watchlistMap(r.Context(), userIDFromRequest(r))
|
||||
if err := templates.StudioAnimeItems(result.Animes, statuses, result.HasNextPage, id, page+1).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
http.Error(w, "Not implemented yet", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"mal/web/templates"
|
||||
"mal/templates"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -21,16 +21,26 @@ func rateLimitErrorFromQuery(r *http.Request) string {
|
||||
if r.URL.Query().Get("error") == "rate_limited" {
|
||||
return rateLimitFormError
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
err := templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{
|
||||
"Error": rateLimitErrorFromQuery(r),
|
||||
"Username": "",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
if renderErr := templates.Login("Something went wrong. Please try again.", "").Render(r.Context(), w); renderErr != nil {
|
||||
log.Printf("render error: %v", renderErr)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{
|
||||
"Error": "Something went wrong. Please try again.",
|
||||
"Username": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -38,34 +48,23 @@ func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
password := r.FormValue("password")
|
||||
|
||||
if username == "" || password == "" {
|
||||
if renderErr := templates.Login("The email or password is wrong.", username).Render(r.Context(), w); renderErr != nil {
|
||||
log.Printf("render error: %v", renderErr)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{
|
||||
"Error": "The email or password is wrong.",
|
||||
"Username": username,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.authService.Login(r.Context(), username, password)
|
||||
if err != nil {
|
||||
if renderErr := templates.Login("The email or password is wrong.", username).Render(r.Context(), w); renderErr != nil {
|
||||
log.Printf("render error: %v", renderErr)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{
|
||||
"Error": "The email or password is wrong.",
|
||||
"Username": username,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
SetSessionCookie(w, session.ID, session.ExpiresAt)
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if err := templates.Login(rateLimitErrorFromQuery(r), "").Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/middleware"
|
||||
"mal/web/components/watch"
|
||||
webcontext "mal/web/context"
|
||||
"mal/web/shared"
|
||||
"mal/web/templates"
|
||||
"mal/templates"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -33,480 +18,23 @@ func NewHandler(svc *Service, jikanClient *jikan.Client) *Handler {
|
||||
}
|
||||
|
||||
func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/watch/")
|
||||
path = strings.Trim(path, "/")
|
||||
if path == "" || strings.HasPrefix(path, "proxy/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
malID, err := strconv.Atoi(parts[0])
|
||||
if err != nil || malID <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Get episode from path if provided, otherwise from query
|
||||
episode := ""
|
||||
if len(parts) >= 2 {
|
||||
episode = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if episode == "" {
|
||||
episode = strings.TrimSpace(r.URL.Query().Get("ep"))
|
||||
}
|
||||
if episode == "" {
|
||||
episode = "1"
|
||||
}
|
||||
|
||||
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Fetch anime details
|
||||
anime, err := h.jikanClient.GetAnimeByID(ctx, malID)
|
||||
if err != nil {
|
||||
log.Printf("failed to fetch anime %d: %v", malID, err)
|
||||
http.Error(w, "Failed to fetch anime details", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if anime.Episodes > 0 {
|
||||
episodeNumber, parseErr := strconv.Atoi(episode)
|
||||
if parseErr == nil && episodeNumber > anime.Episodes {
|
||||
http.Redirect(w, r, "/watch/"+strconv.Itoa(malID)+"/"+strconv.Itoa(anime.Episodes), http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
titleCandidates := playbackTitleCandidates(anime)
|
||||
userID := watchlistUserIDFromRequest(r)
|
||||
data, err := h.svc.BuildWatchPageData(ctx, malID, titleCandidates, episode, mode, userID)
|
||||
if err != nil {
|
||||
log.Printf("watch page error for mal_id=%d: %v", malID, err)
|
||||
http.Error(w, "Failed to load playback", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch episode title for the overlay
|
||||
episodeTitle := ""
|
||||
epNum, epErr := strconv.Atoi(episode)
|
||||
if epErr == nil && epNum > 0 {
|
||||
episodeData, epErr := h.jikanClient.GetEpisode(ctx, malID, epNum)
|
||||
if epErr == nil && episodeData.Data.Title != "" {
|
||||
episodeTitle = episodeData.Data.Title
|
||||
}
|
||||
}
|
||||
|
||||
// Convert playback.WatchPageData to shared.WatchPageData
|
||||
pageData := shared.WatchPageData{
|
||||
MalID: data.MalID,
|
||||
Title: data.Title,
|
||||
TitleEnglish: anime.TitleEnglish,
|
||||
TitleJapanese: anime.TitleJapanese,
|
||||
ImageURL: anime.ImageURL(),
|
||||
Airing: anime.Airing,
|
||||
CurrentEpisode: data.CurrentEpisode,
|
||||
TotalEpisodes: anime.Episodes,
|
||||
StartTimeSeconds: data.StartTimeSeconds,
|
||||
CurrentStatus: data.CurrentStatus,
|
||||
InitialMode: data.InitialMode,
|
||||
AvailableModes: data.AvailableModes,
|
||||
ModeSources: convertModeSources(data.ModeSources),
|
||||
Segments: convertSegments(data.Segments),
|
||||
EpisodeTitle: episodeTitle,
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
if err := watch.VideoPlayer(pageData, anime.DisplayTitle()).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := templates.WatchPage(anime, pageData).Render(r.Context(), w); err != nil {
|
||||
if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", nil); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func watchlistUserIDFromRequest(r *http.Request) string {
|
||||
user, ok := r.Context().Value(webcontext.UserKey).(*database.User)
|
||||
if !ok || user == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return user.ID
|
||||
}
|
||||
|
||||
func playbackTitleCandidates(anime jikan.Anime) []string {
|
||||
out := make([]string, 0, 3+len(anime.TitleSynonyms))
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
add := func(value string) {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.ToLower(normalized)
|
||||
if _, exists := seen[key]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, normalized)
|
||||
}
|
||||
|
||||
add(anime.Title)
|
||||
add(anime.TitleEnglish)
|
||||
add(anime.TitleJapanese)
|
||||
for _, synonym := range anime.TitleSynonyms {
|
||||
add(synonym)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func convertModeSources(sources map[string]ModeSource) map[string]shared.ModeSource {
|
||||
result := make(map[string]shared.ModeSource, len(sources))
|
||||
for k, v := range sources {
|
||||
subtitles := make([]shared.SubtitleItem, len(v.Subtitles))
|
||||
for i, s := range v.Subtitles {
|
||||
subtitles[i] = shared.SubtitleItem{
|
||||
Lang: s.Lang,
|
||||
Token: s.Token,
|
||||
}
|
||||
}
|
||||
result[k] = shared.ModeSource{
|
||||
Token: v.Token,
|
||||
Subtitles: subtitles,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func convertSegments(segments []SkipSegment) []shared.SkipSegment {
|
||||
result := make([]shared.SkipSegment, len(segments))
|
||||
for i, s := range segments {
|
||||
result[i] = shared.SkipSegment{
|
||||
Type: s.Type,
|
||||
Start: s.Start,
|
||||
End: s.End,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(r.URL.Query().Get("token"))
|
||||
if token == "" {
|
||||
http.Error(w, "missing playback token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
scope := proxyScope(strings.TrimPrefix(r.URL.Path, "/watch/proxy/"))
|
||||
scopeLabel := map[proxyScope]string{
|
||||
proxyScopeStream: "stream",
|
||||
proxyScopeSegment: "segment",
|
||||
proxyScopeSubtitle: "subtitle",
|
||||
}[scope]
|
||||
if scopeLabel == "" {
|
||||
http.Error(w, "invalid proxy scope", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
targetURL, referer, err := h.svc.resolveProxyToken(r.Context(), token, scope)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid %s token", scopeLabel), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.proxyUpstream(w, r, targetURL, referer)
|
||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
type saveProgressRequest struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Episode int `json:"episode"`
|
||||
TimeSecond float64 `json:"time_seconds"`
|
||||
}
|
||||
|
||||
var payload saveProgressRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if payload.MalID <= 0 || payload.Episode <= 0 {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
timeSeconds := payload.TimeSecond
|
||||
if timeSeconds < 0 || timeSeconds != timeSeconds {
|
||||
timeSeconds = 0
|
||||
}
|
||||
|
||||
if h.svc.db == nil {
|
||||
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
animeID := int64(payload.MalID)
|
||||
|
||||
animeSeed, err := h.ensureAnimeSeed(r.Context(), payload.MalID)
|
||||
if err != nil {
|
||||
log.Printf("save progress failed to resolve anime user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
|
||||
http.Error(w, "failed to save progress", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.SaveProgress(r.Context(), user.ID, animeID, payload.Episode, timeSeconds, animeSeed); err != nil {
|
||||
log.Printf("save progress failed user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
|
||||
http.Error(w, "failed to save progress", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
type completeAnimeRequest struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Episode int `json:"episode"`
|
||||
}
|
||||
|
||||
var payload completeAnimeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if payload.MalID <= 0 || payload.Episode <= 0 {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
animeID := int64(payload.MalID)
|
||||
animeSeed, err := h.ensureAnimeSeed(r.Context(), payload.MalID)
|
||||
if err != nil {
|
||||
log.Printf("complete anime failed to resolve anime user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
|
||||
http.Error(w, "failed to mark anime completed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.CompleteAnime(r.Context(), user.ID, animeID, payload.Episode, animeSeed); err != nil {
|
||||
log.Printf("complete anime failed user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
|
||||
http.Error(w, "failed to mark anime completed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// HandleEpisodeData returns JSON for episode data (for in-player transitions)
|
||||
func (h *Handler) HandleEpisodeData(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/watch/episode/")
|
||||
path = strings.Trim(path, "/")
|
||||
if path == "" {
|
||||
http.Error(w, "Missing anime ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
malID, err := strconv.Atoi(parts[0])
|
||||
if err != nil || malID <= 0 {
|
||||
http.Error(w, "Invalid anime ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
episode := "1"
|
||||
if len(parts) >= 2 {
|
||||
episode = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if episode == "" {
|
||||
episode = r.URL.Query().Get("ep")
|
||||
}
|
||||
if episode == "" {
|
||||
episode = "1"
|
||||
}
|
||||
|
||||
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
anime, err := h.jikanClient.GetAnimeByID(ctx, malID)
|
||||
if err != nil {
|
||||
log.Printf("failed to fetch anime %d: %v", malID, err)
|
||||
http.Error(w, "Failed to fetch anime details", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
titleCandidates := playbackTitleCandidates(anime)
|
||||
userID := watchlistUserIDFromRequest(r)
|
||||
data, err := h.svc.BuildWatchPageData(ctx, malID, titleCandidates, episode, mode, userID)
|
||||
if err != nil {
|
||||
log.Printf("episode data error for mal_id=%d ep=%s: %v", malID, episode, err)
|
||||
http.Error(w, "Failed to load episode data", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
episodeTitle := ""
|
||||
epNum, epErr := strconv.Atoi(episode)
|
||||
if epErr == nil && epNum > 0 {
|
||||
episodeData, epErr := h.jikanClient.GetEpisode(ctx, malID, epNum)
|
||||
if epErr == nil && episodeData.Data.Title != "" {
|
||||
episodeTitle = episodeData.Data.Title
|
||||
}
|
||||
}
|
||||
|
||||
clientModeSources := convertModeSources(data.ModeSources)
|
||||
initialMode := data.InitialMode
|
||||
token := ""
|
||||
if source, ok := clientModeSources[initialMode]; ok {
|
||||
token = source.Token
|
||||
}
|
||||
|
||||
response := struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Title string `json:"title"`
|
||||
CurrentEpisode string `json:"current_episode"`
|
||||
TotalEpisodes int `json:"total_episodes"`
|
||||
InitialMode string `json:"initial_mode"`
|
||||
Token string `json:"token"`
|
||||
AvailableModes []string `json:"available_modes"`
|
||||
ModeSources map[string]shared.ModeSource `json:"mode_sources"`
|
||||
Segments []shared.SkipSegment `json:"segments"`
|
||||
EpisodeTitle string `json:"episode_title"`
|
||||
}{
|
||||
MalID: malID,
|
||||
Title: data.Title,
|
||||
CurrentEpisode: data.CurrentEpisode,
|
||||
TotalEpisodes: anime.Episodes,
|
||||
InitialMode: initialMode,
|
||||
Token: token,
|
||||
AvailableModes: data.AvailableModes,
|
||||
ModeSources: clientModeSources,
|
||||
Segments: convertToSharedSegments(data.Segments),
|
||||
EpisodeTitle: episodeTitle,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Printf("failed to encode episode data: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func convertToSharedSegments(segments []SkipSegment) []shared.SkipSegment {
|
||||
result := make([]shared.SkipSegment, len(segments))
|
||||
for i, s := range segments {
|
||||
result[i] = shared.SkipSegment{
|
||||
Type: s.Type,
|
||||
Start: s.Start,
|
||||
End: s.End,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *Handler) ensureAnimeSeed(ctx context.Context, malID int) (*database.UpsertAnimeParams, error) {
|
||||
animeID := int64(malID)
|
||||
if _, err := h.svc.db.GetAnime(ctx, animeID); err == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
anime, err := h.jikanClient.GetAnimeByID(ctx, malID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &database.UpsertAnimeParams{
|
||||
ID: animeID,
|
||||
TitleOriginal: anime.Title,
|
||||
TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""},
|
||||
TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""},
|
||||
ImageUrl: anime.ImageURL(),
|
||||
Airing: sql.NullBool{Bool: anime.Airing, Valid: true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) proxyUpstream(w http.ResponseWriter, r *http.Request, targetURL string, referer string) {
|
||||
parsed, err := url.Parse(targetURL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
http.Error(w, "invalid upstream url", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
statusCode, headers, rewrittenBody, streamBody, err := h.svc.ProxyStream(ctx, targetURL, referer, r.Header.Get("Range"))
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) || errors.Is(r.Context().Err(), context.Canceled) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("proxy error for url=%s: %v", targetURL, err)
|
||||
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(statusCode)
|
||||
if len(rewrittenBody) > 0 {
|
||||
_, _ = w.Write(rewrittenBody)
|
||||
return
|
||||
}
|
||||
|
||||
if streamBody == nil {
|
||||
return
|
||||
}
|
||||
defer streamBody.Close()
|
||||
_, _ = io.Copy(w, streamBody)
|
||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
@@ -1,341 +1,38 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"mal/internal/db"
|
||||
"mal/internal/middleware"
|
||||
"mal/web/components/watchlist"
|
||||
"mal/web/templates"
|
||||
"mal/templates"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
func requireMethod(w http.ResponseWriter, r *http.Request, method string) bool {
|
||||
if r.Method == method {
|
||||
return true
|
||||
}
|
||||
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) HandleUpdateWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
w.Header().Set("HX-Redirect", "/login")
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
animeIDStr := r.FormValue("anime_id")
|
||||
animeTitle := r.FormValue("anime_title")
|
||||
animeTitleEnglish := r.FormValue("anime_title_english")
|
||||
animeTitleJapanese := r.FormValue("anime_title_japanese")
|
||||
animeImage := r.FormValue("anime_image")
|
||||
status := r.FormValue("status")
|
||||
airingStr := r.FormValue("airing")
|
||||
airing := airingStr == "true"
|
||||
|
||||
log.Printf("watchlist add: user_id=%s, anime_id=%s, title=%s", user.ID, animeIDStr, animeTitle)
|
||||
|
||||
animeID, err := strconv.ParseInt(animeIDStr, 10, 64)
|
||||
if err != nil || animeID <= 0 {
|
||||
http.Error(w, "invalid anime ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
req := AddRequest{
|
||||
AnimeID: animeID,
|
||||
TitleOriginal: animeTitle,
|
||||
TitleEnglish: animeTitleEnglish,
|
||||
TitleJapanese: animeTitleJapanese,
|
||||
ImageURL: animeImage,
|
||||
Status: status,
|
||||
Airing: airing,
|
||||
}
|
||||
|
||||
if err := h.svc.AddEntry(r.Context(), user.ID, req); err != nil {
|
||||
if errors.Is(err, ErrInvalidAnimeID) || errors.Is(err, ErrInvalidStatus) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("watchlist add failed: user_id=%s anime_id=%d err=%v", user.ID, animeID, err)
|
||||
http.Error(w, "failed to update watchlist", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := watchlist.WatchlistDropdown(int(animeID), animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) HandleCardWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
w.Header().Set("HX-Redirect", "/login")
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
animeIDStr := r.FormValue("anime_id")
|
||||
animeTitle := r.FormValue("anime_title")
|
||||
animeTitleEnglish := r.FormValue("anime_title_english")
|
||||
animeTitleJapanese := r.FormValue("anime_title_japanese")
|
||||
animeImage := r.FormValue("anime_image")
|
||||
airingStr := r.FormValue("airing")
|
||||
airing := airingStr == "true"
|
||||
|
||||
animeID, err := strconv.ParseInt(animeIDStr, 10, 64)
|
||||
if err != nil || animeID <= 0 {
|
||||
http.Error(w, "invalid anime ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
req := AddRequest{
|
||||
AnimeID: animeID,
|
||||
TitleOriginal: animeTitle,
|
||||
TitleEnglish: animeTitleEnglish,
|
||||
TitleJapanese: animeTitleJapanese,
|
||||
ImageURL: animeImage,
|
||||
Status: "plan_to_watch",
|
||||
Airing: airing,
|
||||
}
|
||||
|
||||
if err := h.svc.AddEntry(r.Context(), user.ID, req); err != nil {
|
||||
if errors.Is(err, ErrInvalidAnimeID) || errors.Is(err, ErrInvalidStatus) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("watchlist card add failed: user_id=%s anime_id=%d err=%v", user.ID, animeID, err)
|
||||
http.Error(w, "failed to update watchlist", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := watchlist.CardButton(int(animeID), animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, airing, true).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
func (h *Handler) HandleUpdateWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleDeleteWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
w.Header().Set("HX-Redirect", "/login")
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
path := r.URL.Path[len("/api/watchlist/"):]
|
||||
animeID, err := strconv.ParseInt(path, 10, 64)
|
||||
if err != nil || animeID <= 0 {
|
||||
http.Error(w, "invalid anime ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
anime, err := h.svc.RemoveEntry(r.Context(), user.ID, animeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidAnimeID) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("watchlist delete failed: user_id=%s anime_id=%d err=%v", user.ID, animeID, err)
|
||||
http.Error(w, "failed to delete from watchlist", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
from := r.URL.Query().Get("from")
|
||||
if from == "watchlist" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
title := database.DisplayTitle(anime.TitleEnglish, anime.TitleJapanese, anime.TitleOriginal)
|
||||
airing := false
|
||||
if anime.Airing.Valid {
|
||||
airing = anime.Airing.Bool
|
||||
}
|
||||
|
||||
if from == "card" {
|
||||
if err := watchlist.CardButton(int(animeID), anime.TitleOriginal, title, "", anime.ImageUrl, airing, false).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := watchlist.WatchlistDropdown(int(animeID), anime.TitleOriginal, title, "", anime.ImageUrl, "", airing).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
|
||||
statusFilter := r.URL.Query().Get("status")
|
||||
sortBy := r.URL.Query().Get("sort")
|
||||
sortOrder := r.URL.Query().Get("order")
|
||||
|
||||
if sortBy != "title" {
|
||||
sortBy = "date"
|
||||
}
|
||||
if sortOrder != "desc" {
|
||||
sortOrder = "asc"
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.svc.GetUserWatchlist(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
log.Printf("watchlist fetch failed: user_id=%s err=%v", user.ID, err)
|
||||
http.Error(w, "failed to fetch watchlist", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var filteredEntries []database.GetUserWatchListRow
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
for _, entry := range entries {
|
||||
if entry.Status == statusFilter {
|
||||
filteredEntries = append(filteredEntries, entry)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
statusFilter = "all"
|
||||
filteredEntries = entries
|
||||
}
|
||||
|
||||
// Sort entries
|
||||
h.sortEntries(filteredEntries, sortBy, sortOrder)
|
||||
|
||||
if err := templates.Watchlist(filteredEntries, statusFilter, sortBy, sortOrder).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) HandleContinueWatching(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.svc.GetContinueWatching(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
log.Printf("continue watching fetch failed: user_id=%s err=%v", user.ID, err)
|
||||
http.Error(w, "failed to fetch continue watching", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := templates.ContinueWatching(entries).Render(r.Context(), w); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleDeleteContinueWatching(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
w.Header().Set("HX-Redirect", "/login")
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
path := r.URL.Path[len("/api/continue-watching/"):]
|
||||
animeID, err := strconv.ParseInt(path, 10, 64)
|
||||
if err != nil || animeID <= 0 {
|
||||
http.Error(w, "invalid anime ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteContinueWatching(r.Context(), user.ID, animeID); err != nil {
|
||||
log.Printf("continue watching delete failed: user_id=%s anime_id=%d err=%v", user.ID, animeID, err)
|
||||
http.Error(w, "failed to delete continue watching entry", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (h *Handler) sortEntries(entries []database.GetUserWatchListRow, sortBy, sortOrder string) {
|
||||
isAsc := sortOrder == "asc"
|
||||
|
||||
switch sortBy {
|
||||
case "title":
|
||||
slices.SortFunc(entries, func(a, b database.GetUserWatchListRow) int {
|
||||
if a.TitleOriginal < b.TitleOriginal {
|
||||
return -1
|
||||
}
|
||||
if a.TitleOriginal > b.TitleOriginal {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
if !isAsc {
|
||||
slices.Reverse(entries)
|
||||
}
|
||||
case "date":
|
||||
slices.SortFunc(entries, func(a, b database.GetUserWatchListRow) int {
|
||||
if a.UpdatedAt.After(b.UpdatedAt) {
|
||||
return -1
|
||||
}
|
||||
if a.UpdatedAt.Before(b.UpdatedAt) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
if !isAsc {
|
||||
slices.Reverse(entries)
|
||||
}
|
||||
func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", nil); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user