Merge branch 'upstream/main' into main
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 9m21s
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 9m21s
This commit is contained in:
391
internal/anime/browse_handler.go
Normal file
391
internal/anime/browse_handler.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type producerItem struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type browseQuery struct {
|
||||
q string
|
||||
animeType string
|
||||
status string
|
||||
orderBy string
|
||||
sort string
|
||||
sfw bool
|
||||
studioID int
|
||||
genres []int
|
||||
page int
|
||||
}
|
||||
|
||||
func producerQueryParams(c *gin.Context) (string, int, int, error) {
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("invalid page")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
limit, err := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("invalid limit")
|
||||
}
|
||||
if limit < 1 || limit > 12 {
|
||||
limit = 12
|
||||
}
|
||||
|
||||
return q, page, limit, nil
|
||||
}
|
||||
|
||||
func producerItems(entries []jikan.ProducerListEntry) []producerItem {
|
||||
items := make([]producerItem, 0, len(entries))
|
||||
for _, producer := range entries {
|
||||
name := jikan.ProducerListEntryName(producer)
|
||||
if producer.MalID <= 0 || name == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, producerItem{ID: producer.MalID, Name: name})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func producerHTMLPayload(items []producerItem, hasNextPage bool, page int, q string, limit int) gin.H {
|
||||
return gin.H{
|
||||
"_fragment": "studio_dropdown_items",
|
||||
"StudioItems": items,
|
||||
"HasNextPage": hasNextPage,
|
||||
"Page": page,
|
||||
"NextPage": page + 1,
|
||||
"Query": q,
|
||||
"Limit": limit,
|
||||
}
|
||||
}
|
||||
|
||||
func requestWantsHTML(c *gin.Context) bool {
|
||||
return strings.Contains(c.GetHeader("Accept"), "text/html")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleProducers(c *gin.Context) {
|
||||
q, page, limit, err := producerQueryParams(c)
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.GetProducers(c.Request.Context(), q, page, limit)
|
||||
if err != nil {
|
||||
observability.WarnContext(c.Request.Context(),
|
||||
"producers_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"q": q,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
},
|
||||
err,
|
||||
)
|
||||
if requestWantsHTML(c) {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", producerHTMLPayload([]producerItem{}, false, page, q, limit))
|
||||
return
|
||||
}
|
||||
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"producers_fetch_failed",
|
||||
"anime",
|
||||
"failed to load producers",
|
||||
map[string]any{"q": q, "page": page, "limit": limit},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
items := producerItems(res.Items)
|
||||
|
||||
if requestWantsHTML(c) {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", producerHTMLPayload(items, res.HasNextPage, page, q, limit))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"hasNextPage": res.HasNextPage,
|
||||
"nextPage": page + 1,
|
||||
})
|
||||
}
|
||||
|
||||
func parseBrowseQuery(c *gin.Context) (browseQuery, error) {
|
||||
studioID := 0
|
||||
if raw := strings.TrimSpace(c.Query("studio")); raw != "" {
|
||||
id, err := strconv.Atoi(raw)
|
||||
if err != nil || id < 0 {
|
||||
return browseQuery{}, fmt.Errorf("invalid studio id")
|
||||
}
|
||||
studioID = id
|
||||
}
|
||||
|
||||
genres := make([]int, 0, len(c.QueryArray("genres")))
|
||||
for _, g := range c.QueryArray("genres") {
|
||||
id, err := strconv.Atoi(g)
|
||||
if err != nil {
|
||||
return browseQuery{}, fmt.Errorf("invalid genre id")
|
||||
}
|
||||
if id > 0 {
|
||||
genres = append(genres, id)
|
||||
}
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
return browseQuery{}, fmt.Errorf("invalid page")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
return browseQuery{
|
||||
q: c.Query("q"),
|
||||
animeType: c.Query("type"),
|
||||
status: c.Query("status"),
|
||||
orderBy: c.Query("order_by"),
|
||||
sort: c.Query("sort"),
|
||||
sfw: c.Query("sfw") != "false",
|
||||
studioID: studioID,
|
||||
genres: genres,
|
||||
page: page,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func browseStudioName(ctx context.Context, svc Service, studioID int) string {
|
||||
if studioID <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
name, err := svc.GetProducerNameByID(ctx, studioID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
func browseTemplateData(
|
||||
q browseQuery,
|
||||
studioName string,
|
||||
genresList []domain.Genre,
|
||||
animes []domain.Anime,
|
||||
user any,
|
||||
watchlistMap map[int64]bool,
|
||||
hasNextPage bool,
|
||||
) gin.H {
|
||||
return gin.H{
|
||||
"CurrentPath": "/browse",
|
||||
"Query": q.q,
|
||||
"Type": q.animeType,
|
||||
"Status": q.status,
|
||||
"OrderBy": q.orderBy,
|
||||
"Sort": q.sort,
|
||||
"Genres": q.genres,
|
||||
"Studio": q.studioID,
|
||||
"StudioName": studioName,
|
||||
"SFW": q.sfw,
|
||||
"GenresList": genresList,
|
||||
"Animes": animes,
|
||||
"HasNextPage": hasNextPage,
|
||||
"NextPage": q.page + 1,
|
||||
"User": user,
|
||||
"WatchlistMap": watchlistMap,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) searchBrowse(ctx context.Context, query browseQuery) (jikan.SearchResult, error) {
|
||||
return h.svc.SearchAdvanced(
|
||||
ctx,
|
||||
query.q,
|
||||
query.animeType,
|
||||
query.status,
|
||||
query.orderBy,
|
||||
query.sort,
|
||||
query.genres,
|
||||
query.studioID,
|
||||
query.sfw,
|
||||
query.page,
|
||||
24,
|
||||
)
|
||||
}
|
||||
|
||||
func browseScrollData(
|
||||
query browseQuery,
|
||||
studioName string,
|
||||
animes []domain.Anime,
|
||||
watchlistMap map[int64]bool,
|
||||
hasNextPage bool,
|
||||
) gin.H {
|
||||
return gin.H{
|
||||
"_fragment": "anime_card_scroll",
|
||||
"Animes": animes,
|
||||
"NextPage": query.page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"Query": query.q,
|
||||
"Type": query.animeType,
|
||||
"Status": query.status,
|
||||
"OrderBy": query.orderBy,
|
||||
"Sort": query.sort,
|
||||
"Genres": query.genres,
|
||||
"Studio": query.studioID,
|
||||
"StudioName": studioName,
|
||||
"SFW": query.sfw,
|
||||
"WatchlistMap": watchlistMap,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) respondBrowseSearchError(c *gin.Context, query browseQuery, err error) {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"browse_search_failed",
|
||||
"anime",
|
||||
"failed to load browse results",
|
||||
map[string]any{
|
||||
"q": query.q,
|
||||
"type": query.animeType,
|
||||
"status": query.status,
|
||||
"order_by": query.orderBy,
|
||||
"sort": query.sort,
|
||||
"studio": query.studioID,
|
||||
"sfw": query.sfw,
|
||||
"page": query.page,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
||||
query, err := parseBrowseQuery(c)
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.searchBrowse(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
h.respondBrowseSearchError(c, query, err)
|
||||
return
|
||||
}
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
userID := server.CurrentUserID(c)
|
||||
animes := wrapAnimes(res.Animes)
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||
studioName := browseStudioName(c.Request.Context(), h.svc, query.studioID)
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" && query.page > 1 {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", browseScrollData(query, studioName, animes, watchlistMap, res.HasNextPage))
|
||||
return
|
||||
}
|
||||
|
||||
genresList, _ := h.svc.GetGenres(c.Request.Context())
|
||||
browseData := browseTemplateData(query, studioName, genresList, animes, user, watchlistMap, res.HasNextPage)
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
browseData["_fragment"] = "browse_content"
|
||||
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
||||
}
|
||||
|
||||
type quickSearchResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Year int `json:"year"`
|
||||
Image string `json:"image"`
|
||||
InWatchlist bool `json:"in_watchlist"`
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
userID := server.CurrentUserID(c)
|
||||
animes := wrapAnimes(res.Animes)
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||
|
||||
output := make([]quickSearchResult, len(animes))
|
||||
for i, anime := range animes {
|
||||
output[i] = quickSearchResult{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
Type: anime.Type,
|
||||
Year: anime.Year,
|
||||
Image: anime.ImageURL(),
|
||||
InWatchlist: watchlistMap[int64(anime.MalID)],
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, output)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
anime, err := h.svc.GetRandomAnime(ctx)
|
||||
if err != nil {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"random_anime_fetch_failed",
|
||||
"anime",
|
||||
"failed to fetch random anime",
|
||||
nil,
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if anime.MalID == 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadGateway, "random anime unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
inWatchlist := false
|
||||
userID := server.CurrentUserID(c)
|
||||
if userID != "" {
|
||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, []int64{int64(anime.MalID)})
|
||||
inWatchlist = watchlistMap[int64(anime.MalID)]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": anime,
|
||||
"in_watchlist": inWatchlist,
|
||||
})
|
||||
}
|
||||
123
internal/anime/catalog_handler.go
Normal file
123
internal/anime/catalog_handler.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *AnimeHandler) HandleSearch(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "search.gohtml", gin.H{
|
||||
"User": server.CurrentUser(c),
|
||||
"CurrentPath": "/search",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
|
||||
c.HTML(http.StatusOK, "index.gohtml", gin.H{
|
||||
"CurrentPath": "/",
|
||||
"User": user,
|
||||
"WatchlistMap": map[int64]bool{},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Airing")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Popular")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Continue")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogTopPickForYou(c *gin.Context) {
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
data, err := h.svc.GetTopPickForYou(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
observability.WarnContext(c.Request.Context(),
|
||||
"top_pick_for_you_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = "TopPickForYou"
|
||||
data.Fragment = "top_pick_for_you_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
|
||||
userID := server.CurrentUserID(c)
|
||||
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
|
||||
if err != nil {
|
||||
h.abortSectionFetch(c, "catalog_section_fetch_failed", userID, section, err)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = section
|
||||
data.Fragment = "catalog_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleTopPicks(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
data, err := h.svc.GetTopPicksForYou(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
observability.WarnContext(c.Request.Context(),
|
||||
"top_picks_for_you_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
c.HTML(http.StatusOK, "top_picks.gohtml", gin.H{
|
||||
"CurrentPath": "/top-picks",
|
||||
"User": user,
|
||||
"Animes": data.Animes,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) abortSectionFetch(c *gin.Context, event string, userID string, section string, err error) {
|
||||
observability.WarnContext(c.Request.Context(),
|
||||
event,
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"section": section,
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const commandPaletteAnimeLimit = 24
|
||||
|
||||
type commandPaletteItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
@@ -24,6 +24,12 @@ type commandPaletteItem struct {
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type commandPaletteResponse struct {
|
||||
Items []commandPaletteItem `json:"items"`
|
||||
HasNextPage bool `json:"hasNextPage"`
|
||||
NextPage int `json:"nextPage,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
if user == nil {
|
||||
@@ -32,41 +38,49 @@ func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) {
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(c.Query("q"))
|
||||
items := make([]commandPaletteItem, 0, 12)
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil || page < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid page"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]commandPaletteItem, 0, commandPaletteAnimeLimit)
|
||||
|
||||
if query != "" {
|
||||
items = append(items, commandPaletteItem{
|
||||
ID: "search:" + strings.ToLower(query),
|
||||
Type: "search",
|
||||
Label: fmt.Sprintf("Search anime for %q", query),
|
||||
Subtitle: "Browse",
|
||||
Href: "/browse?q=" + url.QueryEscape(query),
|
||||
Icon: "search",
|
||||
})
|
||||
|
||||
hasNextPage := false
|
||||
if len(query) >= 2 {
|
||||
items = append(items, h.commandPaletteAnimeResults(c, query)...)
|
||||
var animeItems []commandPaletteItem
|
||||
animeItems, hasNextPage = h.commandPaletteAnimeResults(c, query, page)
|
||||
items = append(items, animeItems...)
|
||||
}
|
||||
|
||||
items = append(items, h.commandPaletteNavigationItems(query)...)
|
||||
items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...)
|
||||
items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...)
|
||||
c.JSON(http.StatusOK, items)
|
||||
if page == 1 {
|
||||
items = append(items, h.commandPaletteNavigationItems(query)...)
|
||||
items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...)
|
||||
items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, commandPaletteResponse{
|
||||
Items: items,
|
||||
HasNextPage: hasNextPage,
|
||||
NextPage: page + 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...)
|
||||
items = append(items, h.commandPaletteNavigationItems(query)...)
|
||||
items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...)
|
||||
c.JSON(http.StatusOK, items)
|
||||
c.JSON(http.StatusOK, commandPaletteResponse{Items: items})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPaletteItem {
|
||||
all := []commandPaletteItem{
|
||||
{ID: "nav:discover", Type: "navigation", Label: "Go to Discover", Subtitle: "Navigation", Href: "/discover", Icon: "compass"},
|
||||
{ID: "nav:home", Type: "navigation", Label: "Go to Home", Subtitle: "Navigation", Href: "/", Icon: "home"},
|
||||
{ID: "nav:watchlist", Type: "navigation", Label: "Go to Watchlist", Subtitle: "Navigation", Href: "/watchlist", Icon: "bookmark"},
|
||||
{ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=desc", Icon: "trending"},
|
||||
{ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=desc", Icon: "play"},
|
||||
{ID: "nav:top-picks", Type: "navigation", Label: "Open Top Picks", Subtitle: "Navigation", Href: "/top-picks", Icon: "sparkles"},
|
||||
{ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=asc", Icon: "trending"},
|
||||
{ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=asc", Icon: "play"},
|
||||
}
|
||||
if query == "" {
|
||||
return all
|
||||
@@ -81,13 +95,10 @@ func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPale
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string) []commandPaletteItem {
|
||||
searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, 0, true, 1, 5)
|
||||
func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string, page int) ([]commandPaletteItem, bool) {
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, page, commandPaletteAnimeLimit)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, false
|
||||
}
|
||||
|
||||
animes := wrapAnimes(res.Animes)
|
||||
@@ -102,7 +113,7 @@ func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string)
|
||||
Image: anime.ImageURL(),
|
||||
})
|
||||
}
|
||||
return items
|
||||
return items, res.HasNextPage
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string, query string) []commandPaletteItem {
|
||||
|
||||
220
internal/anime/details_handler.go
Normal file
220
internal/anime/details_handler.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
animeSectionTimeout = 12 * time.Second
|
||||
watchOrderTimeout = 15 * time.Second
|
||||
audioLookupTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
|
||||
hasKnownSub := false
|
||||
for _, episode := range episodes {
|
||||
if episode.HasDub {
|
||||
return "Dub available"
|
||||
}
|
||||
if episode.HasSub || episode.SubOnly {
|
||||
hasKnownSub = true
|
||||
}
|
||||
}
|
||||
if hasKnownSub {
|
||||
return "Subtitled only"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) animeAudioAvailability(ctx context.Context, anime domain.Anime) string {
|
||||
if h.episodeSvc == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
audioCtx, cancel := context.WithTimeout(ctx, audioLookupTimeout)
|
||||
defer cancel()
|
||||
|
||||
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(audioCtx, anime, true)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"anime_audio_availability_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return ""
|
||||
}
|
||||
if episodeList.Source != "AllAnime" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return animeAudioAvailabilityLabel(episodeList.Episodes)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||
return
|
||||
}
|
||||
|
||||
section := c.Query("section")
|
||||
if section != "" && c.GetHeader("HX-Request") == "true" {
|
||||
h.handleAnimeDetailsSection(c, id, section)
|
||||
return
|
||||
}
|
||||
|
||||
anime, err := h.svc.GetAnimeByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h.svc.WarmDetailSections(id)
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
status := ""
|
||||
var watchlistIDs []int64
|
||||
ep := 0
|
||||
var cwSeconds float64
|
||||
if user != nil {
|
||||
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), user.ID, int64(id))
|
||||
if err == nil {
|
||||
status = entry.Status
|
||||
watchlistIDs = []int64{entry.AnimeID}
|
||||
}
|
||||
|
||||
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), user.ID, int64(id))
|
||||
if err == nil && cwEntry.CurrentEpisode.Valid {
|
||||
ep = int(cwEntry.CurrentEpisode.Int64)
|
||||
cwSeconds = cwEntry.CurrentTimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
audioAvailability := h.animeAudioAvailability(c.Request.Context(), anime)
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"Anime": anime,
|
||||
"AudioAvailability": audioAvailability,
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d", id),
|
||||
"User": user,
|
||||
"Status": status,
|
||||
"WatchlistIDs": watchlistIDs,
|
||||
"ContinueWatchingEp": ep,
|
||||
"ContinueWatchingTime": cwSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) handleAnimeDetailsSection(c *gin.Context, id int, section string) {
|
||||
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), animeSectionTimeout)
|
||||
defer cancel()
|
||||
|
||||
data, tplName, err := h.loadAnimeDetailsSection(sectionCtx, id, section)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"anime_section_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"section": section,
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
if section == "recommendations" {
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "anime_recommendations_loading",
|
||||
"AnimeID": id,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": tplName,
|
||||
"Items": data,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) loadAnimeDetailsSection(ctx context.Context, id int, section string) (any, string, error) {
|
||||
switch section {
|
||||
case "characters":
|
||||
data, err := h.svc.GetCharacters(ctx, id)
|
||||
return data, "anime_characters", err
|
||||
case "recommendations":
|
||||
data, err := h.svc.GetRecommendations(ctx, id)
|
||||
return data, "anime_recommendations", err
|
||||
case "statistics":
|
||||
data, err := h.svc.GetStatistics(ctx, id)
|
||||
return data, "anime_statistics", err
|
||||
case "themes":
|
||||
data, err := h.svc.GetThemes(ctx, id)
|
||||
return data, "anime_themes", err
|
||||
default:
|
||||
return nil, "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Query("animeId"))
|
||||
if err != nil || id <= 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||
return
|
||||
}
|
||||
|
||||
userID := server.CurrentUserID(c)
|
||||
mode := jikan.NormalizeWatchOrderMode(c.Query("mode"))
|
||||
|
||||
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), watchOrderTimeout)
|
||||
defer cancel()
|
||||
|
||||
relations, err := h.svc.GetRelations(relationsCtx, id, mode)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"relations_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "watch_order_loading",
|
||||
"AnimeID": id,
|
||||
"Mode": string(mode),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
relationAnimeIDs := make([]int64, 0, len(relations))
|
||||
for _, relation := range relations {
|
||||
if relation.Anime.MalID > 0 {
|
||||
relationAnimeIDs = append(relationAnimeIDs, int64(relation.Anime.MalID))
|
||||
}
|
||||
}
|
||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, relationAnimeIDs)
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "watch_order",
|
||||
"Relations": relations,
|
||||
"AnimeID": id,
|
||||
"Mode": string(mode),
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
}
|
||||
@@ -2,38 +2,22 @@ package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
animeSectionTimeout = 12 * time.Second
|
||||
watchOrderTimeout = 15 * time.Second
|
||||
audioLookupTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
type AnimeHandler struct {
|
||||
svc Service
|
||||
watchlistSvc domain.WatchlistService
|
||||
episodeSvc domain.EpisodeService
|
||||
|
||||
scheduleCacheMu sync.Mutex
|
||||
scheduleCache map[string]cachedWeekSchedule
|
||||
svc Service
|
||||
watchlistSvc domain.WatchlistService
|
||||
episodeSvc domain.EpisodeService
|
||||
scheduleCache map[string]cachedWeekSchedule
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
domain.AnimeCatalogService
|
||||
domain.AnimeDiscoverService
|
||||
domain.AnimeSearchService
|
||||
domain.AnimeDetailsService
|
||||
WarmDetailSections(id int)
|
||||
@@ -44,7 +28,7 @@ func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeS
|
||||
svc: svc,
|
||||
watchlistSvc: watchlistSvc,
|
||||
episodeSvc: episodeSvc,
|
||||
scheduleCache: map[string]cachedWeekSchedule{},
|
||||
scheduleCache: make(map[string]cachedWeekSchedule),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,63 +54,14 @@ func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, an
|
||||
return watchlistMap
|
||||
}
|
||||
|
||||
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
|
||||
hasKnownSub := false
|
||||
for _, episode := range episodes {
|
||||
if episode.HasDub {
|
||||
return "Dub available"
|
||||
}
|
||||
if episode.HasSub || episode.SubOnly {
|
||||
hasKnownSub = true
|
||||
}
|
||||
}
|
||||
if hasKnownSub {
|
||||
return "Subtitled only"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) animeAudioAvailability(ctx context.Context, anime domain.Anime) string {
|
||||
if h.episodeSvc == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
audioCtx, cancel := context.WithTimeout(ctx, audioLookupTimeout)
|
||||
defer cancel()
|
||||
|
||||
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(audioCtx, anime, true)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"anime_audio_availability_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return ""
|
||||
}
|
||||
if episodeList.Source != "AllAnime" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return animeAudioAvailabilityLabel(episodeList.Episodes)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) Register(r *gin.Engine) {
|
||||
r.GET("/", h.HandleCatalog)
|
||||
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
|
||||
r.GET("/api/catalog/popular", h.HandleCatalogPopular)
|
||||
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
|
||||
r.GET("/api/catalog/top-pick", h.HandleCatalogTopPickForYou)
|
||||
r.GET("/discover", h.HandleDiscover)
|
||||
r.GET("/discover/top-picks", h.HandleDiscoverTopPicksForYou)
|
||||
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
||||
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
||||
r.GET("/api/discover/top", h.HandleDiscoverTop)
|
||||
r.GET("/schedule", h.HandleSchedule)
|
||||
r.GET("/api/schedule", h.HandleScheduleSection)
|
||||
r.GET("/search", h.HandleSearch)
|
||||
r.GET("/top-picks", h.HandleTopPicks)
|
||||
r.GET("/browse", h.HandleBrowse)
|
||||
r.GET("/anime/:id", h.HandleAnimeDetails)
|
||||
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
||||
@@ -136,700 +71,3 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
|
||||
r.GET("/api/jikan/random/anime", h.HandleRandomAnime)
|
||||
r.GET("/api/jikan/producers", h.HandleProducers)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleProducers(c *gin.Context) {
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
||||
return
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit, err := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 12
|
||||
}
|
||||
if limit > 12 {
|
||||
limit = 12
|
||||
}
|
||||
|
||||
res, err := h.svc.GetProducers(c.Request.Context(), q, page, limit)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"producers_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"q": q,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
},
|
||||
err,
|
||||
)
|
||||
if strings.Contains(c.GetHeader("Accept"), "text/html") {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"_fragment": "studio_dropdown_items",
|
||||
"StudioItems": []any{},
|
||||
"HasNextPage": false,
|
||||
"Page": page,
|
||||
"NextPage": page + 1,
|
||||
"Query": q,
|
||||
"Limit": limit,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"producers_fetch_failed",
|
||||
"anime",
|
||||
"failed to load producers",
|
||||
map[string]any{"q": q, "page": page, "limit": limit},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
type item struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
items := make([]item, 0, len(res.Items))
|
||||
for _, p := range res.Items {
|
||||
name := jikan.ProducerListEntryName(p)
|
||||
if p.MalID <= 0 || name == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{ID: p.MalID, Name: name})
|
||||
}
|
||||
|
||||
if strings.Contains(c.GetHeader("Accept"), "text/html") {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"_fragment": "studio_dropdown_items",
|
||||
"StudioItems": items,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"Page": page,
|
||||
"NextPage": page + 1,
|
||||
"Query": q,
|
||||
"Limit": limit,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"hasNextPage": res.HasNextPage,
|
||||
"nextPage": page + 1,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
|
||||
c.HTML(http.StatusOK, "index.gohtml", gin.H{
|
||||
"CurrentPath": "/",
|
||||
"User": user,
|
||||
"WatchlistMap": map[int64]bool{},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Airing")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Popular")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Continue")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogTopPickForYou(c *gin.Context) {
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
data, err := h.svc.GetTopPickForYou(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"top_pick_for_you_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = "TopPickForYou"
|
||||
data.Fragment = "top_pick_for_you_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
|
||||
userID := server.CurrentUserID(c)
|
||||
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
|
||||
if err != nil {
|
||||
h.abortSectionFetch(c, "catalog_section_fetch_failed", userID, section, err)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = section
|
||||
data.Fragment = "catalog_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscover(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
|
||||
"CurrentPath": "/discover",
|
||||
"User": user,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverTopPicksForYou(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
data, err := h.svc.GetTopPicksForYou(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"top_picks_for_you_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
|
||||
"_fragment": "",
|
||||
"CurrentPath": "/discover",
|
||||
"User": user,
|
||||
"Animes": data.Animes,
|
||||
"WatchlistMap": watchlistMap,
|
||||
"IsTopPicks": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverTrending(c *gin.Context) {
|
||||
h.renderDiscoverSection(c, "Trending")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverUpcoming(c *gin.Context) {
|
||||
h.renderDiscoverSection(c, "Upcoming")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) {
|
||||
h.renderDiscoverSection(c, "Top")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
|
||||
userID := server.CurrentUserID(c)
|
||||
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
|
||||
if err != nil {
|
||||
h.abortSectionFetch(c, "discover_section_fetch_failed", userID, section, err)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = section
|
||||
data.Fragment = "discover_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "discover.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) abortSectionFetch(c *gin.Context, event string, userID string, section string, err error) {
|
||||
observability.Warn(
|
||||
event,
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"section": section,
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleSchedule(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
year, week := parseYearWeek(c)
|
||||
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
|
||||
"CurrentPath": "/schedule",
|
||||
"User": user,
|
||||
"ScheduleYear": year,
|
||||
"ScheduleWeek": week,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) {
|
||||
year, week := parseYearWeek(c)
|
||||
timezone := scheduleTimezone(c)
|
||||
|
||||
schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week, timezone)
|
||||
if err != nil {
|
||||
prevYear, prevWeek := adjacentISOWeek(year, week, -1)
|
||||
nextYear, nextWeek := adjacentISOWeek(year, week, 1)
|
||||
observability.Warn(
|
||||
"animeschedule_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"year": year,
|
||||
"week": week,
|
||||
"timezone": timezone,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
|
||||
"_fragment": "schedule_section_scraped",
|
||||
"ScheduleDays": []any{},
|
||||
"ScheduleYear": year,
|
||||
"ScheduleWeek": week,
|
||||
"PrevYear": prevYear,
|
||||
"PrevWeek": prevWeek,
|
||||
"NextYear": nextYear,
|
||||
"NextWeek": nextWeek,
|
||||
"ScheduleError": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
days := buildScheduleDays(schedule, schedule.Year, schedule.Week)
|
||||
prevYear, prevWeek := adjacentISOWeek(schedule.Year, schedule.Week, -1)
|
||||
nextYear, nextWeek := adjacentISOWeek(schedule.Year, schedule.Week, 1)
|
||||
|
||||
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
|
||||
"_fragment": "schedule_section_scraped",
|
||||
"ScheduleDays": days,
|
||||
"ScheduleYear": schedule.Year,
|
||||
"ScheduleWeek": schedule.Week,
|
||||
"PrevYear": prevYear,
|
||||
"PrevWeek": prevWeek,
|
||||
"NextYear": nextYear,
|
||||
"NextWeek": nextWeek,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
||||
q := c.Query("q")
|
||||
animeType := c.Query("type")
|
||||
status := c.Query("status")
|
||||
orderBy := c.Query("order_by")
|
||||
sort := c.Query("sort")
|
||||
sfw := c.Query("sfw") != "false"
|
||||
studioID := 0
|
||||
if raw := strings.TrimSpace(c.Query("studio")); raw != "" {
|
||||
id, err := strconv.Atoi(raw)
|
||||
if err != nil || id < 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid studio id")
|
||||
return
|
||||
}
|
||||
studioID = id
|
||||
}
|
||||
|
||||
var genres []int
|
||||
for _, g := range c.QueryArray("genres") {
|
||||
id, err := strconv.Atoi(g)
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid genre id")
|
||||
return
|
||||
}
|
||||
if id > 0 {
|
||||
genres = append(genres, id)
|
||||
}
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
||||
return
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, studioID, sfw, page, 24)
|
||||
if err != nil {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"browse_search_failed",
|
||||
"anime",
|
||||
"failed to load browse results",
|
||||
map[string]any{
|
||||
"q": q,
|
||||
"type": animeType,
|
||||
"status": status,
|
||||
"order_by": orderBy,
|
||||
"sort": sort,
|
||||
"studio": studioID,
|
||||
"sfw": sfw,
|
||||
"page": page,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
userID := server.CurrentUserID(c)
|
||||
animes := wrapAnimes(res.Animes)
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||
|
||||
studioName := ""
|
||||
if studioID > 0 {
|
||||
name, err := h.svc.GetProducerNameByID(c.Request.Context(), studioID)
|
||||
if err == nil {
|
||||
studioName = name
|
||||
}
|
||||
}
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" && page > 1 {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"_fragment": "anime_card_scroll",
|
||||
"Animes": animes,
|
||||
"NextPage": page + 1,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"Query": q,
|
||||
"Type": animeType,
|
||||
"Status": status,
|
||||
"OrderBy": orderBy,
|
||||
"Sort": sort,
|
||||
"Genres": genres,
|
||||
"Studio": studioID,
|
||||
"StudioName": studioName,
|
||||
"SFW": sfw,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
genresList, _ := h.svc.GetGenres(c.Request.Context())
|
||||
browseData := gin.H{
|
||||
"CurrentPath": "/browse",
|
||||
"Query": q,
|
||||
"Type": animeType,
|
||||
"Status": status,
|
||||
"OrderBy": orderBy,
|
||||
"Sort": sort,
|
||||
"Genres": genres,
|
||||
"Studio": studioID,
|
||||
"StudioName": studioName,
|
||||
"SFW": sfw,
|
||||
"GenresList": genresList,
|
||||
"Animes": animes,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"NextPage": page + 1,
|
||||
"User": user,
|
||||
"WatchlistMap": watchlistMap,
|
||||
}
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
browseData["_fragment"] = "browse_content"
|
||||
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||
return
|
||||
}
|
||||
|
||||
section := c.Query("section")
|
||||
if section != "" && c.GetHeader("HX-Request") == "true" {
|
||||
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), animeSectionTimeout)
|
||||
defer cancel()
|
||||
|
||||
var data any
|
||||
var tplName string
|
||||
var err error
|
||||
switch section {
|
||||
case "characters":
|
||||
data, err = h.svc.GetCharacters(sectionCtx, id)
|
||||
tplName = "anime_characters"
|
||||
case "recommendations":
|
||||
data, err = h.svc.GetRecommendations(sectionCtx, id)
|
||||
tplName = "anime_recommendations"
|
||||
|
||||
case "statistics":
|
||||
data, err = h.svc.GetStatistics(sectionCtx, id)
|
||||
tplName = "anime_statistics"
|
||||
case "themes":
|
||||
data, err = h.svc.GetThemes(sectionCtx, id)
|
||||
tplName = "anime_themes"
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"anime_section_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"section": section,
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
if section == "recommendations" {
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "anime_recommendations_loading",
|
||||
"AnimeID": id,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": tplName,
|
||||
"Items": data,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
anime, err := h.svc.GetAnimeByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h.svc.WarmDetailSections(id)
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
status := ""
|
||||
var watchlistIDs []int64
|
||||
ep := 0
|
||||
var cwSeconds float64
|
||||
if user != nil {
|
||||
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), user.ID, int64(id))
|
||||
if err == nil {
|
||||
status = entry.Status
|
||||
watchlistIDs = []int64{entry.AnimeID}
|
||||
}
|
||||
|
||||
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), user.ID, int64(id))
|
||||
if err == nil && cwEntry.CurrentEpisode.Valid {
|
||||
ep = int(cwEntry.CurrentEpisode.Int64)
|
||||
cwSeconds = cwEntry.CurrentTimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
audioAvailability := h.animeAudioAvailability(c.Request.Context(), anime)
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"Anime": anime,
|
||||
"AudioAvailability": audioAvailability,
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d", id),
|
||||
"User": user,
|
||||
"Status": status,
|
||||
"WatchlistIDs": watchlistIDs,
|
||||
"ContinueWatchingEp": ep,
|
||||
"ContinueWatchingTime": cwSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Query("animeId"))
|
||||
if err != nil || id <= 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||
return
|
||||
}
|
||||
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), watchOrderTimeout)
|
||||
defer cancel()
|
||||
|
||||
relations, err := h.svc.GetRelations(relationsCtx, id)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"relations_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "watch_order_loading",
|
||||
"AnimeID": id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
relationAnimeIDs := make([]int64, 0, len(relations))
|
||||
for _, relation := range relations {
|
||||
if relation.Anime.MalID > 0 {
|
||||
relationAnimeIDs = append(relationAnimeIDs, int64(relation.Anime.MalID))
|
||||
}
|
||||
}
|
||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, relationAnimeIDs)
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "watch_order",
|
||||
"Relations": relations,
|
||||
"AnimeID": id,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
userID := server.CurrentUserID(c)
|
||||
animes := wrapAnimes(res.Animes)
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||
|
||||
type quickSearchResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Year int `json:"year"`
|
||||
Image string `json:"image"`
|
||||
InWatchlist bool `json:"in_watchlist"`
|
||||
}
|
||||
|
||||
output := make([]quickSearchResult, len(animes))
|
||||
for i, anime := range animes {
|
||||
output[i] = quickSearchResult{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
Type: anime.Type,
|
||||
Year: anime.Year,
|
||||
Image: anime.ImageURL(),
|
||||
InWatchlist: watchlistMap[int64(anime.MalID)],
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, output)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
anime, err := h.svc.GetRandomAnime(ctx)
|
||||
if err != nil {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"random_anime_fetch_failed",
|
||||
"anime",
|
||||
"failed to fetch random anime",
|
||||
nil,
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if anime.MalID == 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadGateway, "random anime unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
inWatchlist := false
|
||||
userID := server.CurrentUserID(c)
|
||||
if userID != "" {
|
||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, []int64{int64(anime.MalID)})
|
||||
inWatchlist = watchlistMap[int64(anime.MalID)]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": anime,
|
||||
"in_watchlist": inWatchlist,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||
return
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
||||
return
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), id, page)
|
||||
if err != nil {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"anime_reviews_fetch_failed",
|
||||
"anime",
|
||||
"failed to load reviews",
|
||||
map[string]any{"anime_id": id, "page": page},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" && page > 1 {
|
||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||
"_fragment": "review_cards",
|
||||
"Reviews": reviews,
|
||||
"NextPage": page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"AnimeID": id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d/reviews", id),
|
||||
"Reviews": reviews,
|
||||
"NextPage": page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"AnimeID": id,
|
||||
"User": user,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ var Module = fx.Options(
|
||||
NewAnimeService,
|
||||
fx.As(new(Service)),
|
||||
fx.As(new(domain.AnimeCatalogService)),
|
||||
fx.As(new(domain.AnimeDiscoverService)),
|
||||
fx.As(new(domain.AnimeSearchService)),
|
||||
fx.As(new(domain.AnimeDetailsService)),
|
||||
fx.As(new(domain.AnimePlaybackService)),
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -270,6 +275,24 @@ func scoreRecommendationCandidate(
|
||||
score += themeScore * forYouThemeMatchWeight
|
||||
score += studioScore * forYouStudioMatchWeight
|
||||
score += demographicScore * forYouDemographicMatchWeight
|
||||
score += recommendationCandidateScoreAdjustments(now, profile, candidate)
|
||||
|
||||
return recommendationCandidate{
|
||||
anime: candidate,
|
||||
score: score,
|
||||
genreMatches: genreMatches,
|
||||
themeMatches: themeMatches,
|
||||
studioMatches: studioMatches,
|
||||
demographicMatches: demographicMatches,
|
||||
}
|
||||
}
|
||||
|
||||
func recommendationCandidateScoreAdjustments(
|
||||
now time.Time,
|
||||
profile userTasteProfile,
|
||||
candidate jikan.Anime,
|
||||
) float64 {
|
||||
var score float64
|
||||
|
||||
if candidate.Score > 0 {
|
||||
score += min(candidate.Score/10.0, 1.0)
|
||||
@@ -280,31 +303,41 @@ func scoreRecommendationCandidate(
|
||||
if profile.prefersAiring && candidate.Airing {
|
||||
score += 0.5
|
||||
}
|
||||
if profile.prefersRecent && candidate.Year > 0 && now.Year()-candidate.Year <= 4 {
|
||||
if profile.prefersRecent && isRecentCandidate(now, candidate.Year) {
|
||||
score += 0.45
|
||||
}
|
||||
if candidate.Year > 0 && now.Year()-candidate.Year > 15 {
|
||||
if isClassicCandidate(now, candidate.Year) {
|
||||
score -= 0.2
|
||||
}
|
||||
if candidate.Status == "Not yet aired" {
|
||||
score -= 0.35
|
||||
}
|
||||
if candidate.Aired.From != "" {
|
||||
if airedAt, err := time.Parse(time.RFC3339, candidate.Aired.From); err == nil {
|
||||
if now.Sub(airedAt) <= forYouFreshReleaseWindow {
|
||||
score += 0.3
|
||||
}
|
||||
}
|
||||
if isFreshRelease(now, candidate.Aired.From) {
|
||||
score += 0.3
|
||||
}
|
||||
|
||||
return recommendationCandidate{
|
||||
anime: candidate,
|
||||
score: score,
|
||||
genreMatches: genreMatches,
|
||||
themeMatches: themeMatches,
|
||||
studioMatches: studioMatches,
|
||||
demographicMatches: demographicMatches,
|
||||
return score
|
||||
}
|
||||
|
||||
func isRecentCandidate(now time.Time, year int) bool {
|
||||
return year > 0 && now.Year()-year <= 4
|
||||
}
|
||||
|
||||
func isClassicCandidate(now time.Time, year int) bool {
|
||||
return year > 0 && now.Year()-year > 15
|
||||
}
|
||||
|
||||
func isFreshRelease(now time.Time, airedFrom string) bool {
|
||||
if airedFrom == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
airedAt, err := time.Parse(time.RFC3339, airedFrom)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return now.Sub(airedAt) <= forYouFreshReleaseWindow
|
||||
}
|
||||
|
||||
func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity) (int, float64) {
|
||||
@@ -501,3 +534,298 @@ func recentFeatureCounts(
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
type rankedCandidate struct {
|
||||
id int
|
||||
collaborativeScore float64
|
||||
profileSearchScore float64
|
||||
anime jikan.Anime
|
||||
hasAnime bool
|
||||
}
|
||||
|
||||
type candidateStore struct {
|
||||
watchlistAnimeIDs map[int]struct{}
|
||||
byID map[int]rankedCandidate
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newCandidateStore(watchlist []db.GetUserWatchListRow) *candidateStore {
|
||||
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
|
||||
for _, entry := range watchlist {
|
||||
if entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
|
||||
}
|
||||
|
||||
return &candidateStore{
|
||||
watchlistAnimeIDs: watchlistAnimeIDs,
|
||||
byID: map[int]rankedCandidate{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *candidateStore) upsert(candidate rankedCandidate) {
|
||||
if candidate.id <= 0 {
|
||||
return
|
||||
}
|
||||
if _, exists := s.watchlistAnimeIDs[candidate.id]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
current, ok := s.byID[candidate.id]
|
||||
if !ok {
|
||||
s.byID[candidate.id] = candidate
|
||||
return
|
||||
}
|
||||
|
||||
current.collaborativeScore += candidate.collaborativeScore
|
||||
current.profileSearchScore += candidate.profileSearchScore
|
||||
if candidate.hasAnime {
|
||||
current.anime = candidate.anime
|
||||
current.hasAnime = true
|
||||
}
|
||||
s.byID[candidate.id] = current
|
||||
}
|
||||
|
||||
func (s *candidateStore) ranked() []rankedCandidate {
|
||||
ranked := make([]rankedCandidate, 0, len(s.byID))
|
||||
for _, item := range s.byID {
|
||||
ranked = append(ranked, item)
|
||||
}
|
||||
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
left := rankedCandidateRetrievalScore(ranked[i].collaborativeScore, ranked[i].profileSearchScore)
|
||||
right := rankedCandidateRetrievalScore(ranked[j].collaborativeScore, ranked[j].profileSearchScore)
|
||||
if left == right {
|
||||
return ranked[i].id < ranked[j].id
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
|
||||
return ranked
|
||||
}
|
||||
|
||||
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
return s.getTopPicksForYou(ctx, userID, forYouResultLimit)
|
||||
}
|
||||
|
||||
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
return s.getTopPicksForYou(ctx, userID, forYouFullResultLimit)
|
||||
}
|
||||
|
||||
func (s *animeService) fetchSeedAnimes(ctx context.Context, seedPool []recommendationSeed) ([]jikan.Anime, error) {
|
||||
seedAnimes := make([]jikan.Anime, len(seedPool))
|
||||
var g errgroup.Group
|
||||
g.SetLimit(4)
|
||||
|
||||
for i, seed := range seedPool {
|
||||
g.Go(func() error {
|
||||
anime, err := s.jikan.GetAnimeByID(ctx, seed.animeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seedAnimes[i] = anime
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return seedAnimes, nil
|
||||
}
|
||||
|
||||
func (s *animeService) collectCollaborativeCandidates(ctx context.Context, seedPool []recommendationSeed, store *candidateStore) error {
|
||||
var g errgroup.Group
|
||||
g.SetLimit(4)
|
||||
|
||||
for _, seed := range seedPool {
|
||||
g.Go(func() error {
|
||||
recs, err := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if i >= forYouMaxRecommendations {
|
||||
break
|
||||
}
|
||||
id := rec.Entry.MalID
|
||||
if id <= 0 || id == seed.animeID {
|
||||
continue
|
||||
}
|
||||
store.upsert(rankedCandidate{
|
||||
id: id,
|
||||
collaborativeScore: float64(rec.Votes) * seed.weight,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
func (s *animeService) collectProfileSearchCandidates(ctx context.Context, profile userTasteProfile, store *candidateStore) error {
|
||||
queries := buildProfileSearchQueries(profile)
|
||||
var g errgroup.Group
|
||||
g.SetLimit(3)
|
||||
|
||||
for _, query := range queries {
|
||||
g.Go(func() error {
|
||||
res, err := s.jikan.SearchAdvanced(
|
||||
ctx,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"score",
|
||||
"desc",
|
||||
query.genreIDs,
|
||||
query.studioID,
|
||||
true,
|
||||
1,
|
||||
forYouProfileSearchLimit,
|
||||
)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"top_pick_profile_search_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"genres": query.genreIDs,
|
||||
"studio_id": query.studioID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, anime := range res.Animes {
|
||||
if anime.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
store.upsert(rankedCandidate{
|
||||
id: anime.MalID,
|
||||
profileSearchScore: query.weight * profileSearchRankWeight(i),
|
||||
anime: anime,
|
||||
hasAnime: true,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
func (s *animeService) scoreRankedCandidates(
|
||||
ctx context.Context,
|
||||
now time.Time,
|
||||
profile userTasteProfile,
|
||||
ranked []rankedCandidate,
|
||||
) ([]recommendationCandidate, error) {
|
||||
limit := min(len(ranked), forYouCandidateFetchLimit)
|
||||
candidates := make([]recommendationCandidate, 0, limit)
|
||||
var candidatesMu sync.Mutex
|
||||
var g errgroup.Group
|
||||
g.SetLimit(6)
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
item := ranked[i]
|
||||
g.Go(func() error {
|
||||
anime := item.anime
|
||||
if !item.hasAnime || !hasTasteMetadata(anime) {
|
||||
fetchedAnime, err := s.jikan.GetAnimeByID(ctx, item.id)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"recommendation_anime_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{"anime_id": item.id},
|
||||
err,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
anime = fetchedAnime
|
||||
}
|
||||
|
||||
candidate := scoreRecommendationCandidate(
|
||||
now,
|
||||
profile,
|
||||
anime,
|
||||
item.collaborativeScore,
|
||||
item.profileSearchScore,
|
||||
)
|
||||
candidatesMu.Lock()
|
||||
candidates = append(candidates, candidate)
|
||||
candidatesMu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].score == candidates[j].score {
|
||||
return candidates[i].anime.MalID < candidates[j].anime.MalID
|
||||
}
|
||||
return candidates[i].score > candidates[j].score
|
||||
})
|
||||
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (s *animeService) getTopPicksForYou(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
resultLimit int,
|
||||
) (domain.CatalogSectionData, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
seedPool := buildRecommendationSeeds(now, watchlist)
|
||||
if len(seedPool) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
seedAnimes, err := s.fetchSeedAnimes(ctx, seedPool)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
profile := buildTasteProfile(now, seedPool, seedAnimes)
|
||||
store := newCandidateStore(watchlist)
|
||||
|
||||
if err := s.collectCollaborativeCandidates(ctx, seedPool, store); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
if err := s.collectProfileSearchCandidates(ctx, profile, store); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
ranked := store.ranked()
|
||||
if len(ranked) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
candidates, err := s.scoreRankedCandidates(ctx, now, profile, ranked)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
return domain.CatalogSectionData{
|
||||
Animes: rerankRecommendationCandidates(candidates, resultLimit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
76
internal/anime/reviews_handler.go
Normal file
76
internal/anime/reviews_handler.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type reviewsQuery struct {
|
||||
animeID int
|
||||
page int
|
||||
}
|
||||
|
||||
func parseReviewsQuery(c *gin.Context) (reviewsQuery, error) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
return reviewsQuery{}, fmt.Errorf("invalid anime id")
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
return reviewsQuery{}, fmt.Errorf("invalid page")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
return reviewsQuery{animeID: id, page: page}, nil
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
|
||||
query, err := parseReviewsQuery(c)
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), query.animeID, query.page)
|
||||
if err != nil {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"anime_reviews_fetch_failed",
|
||||
"anime",
|
||||
"failed to load reviews",
|
||||
map[string]any{"anime_id": query.animeID, "page": query.page},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" && query.page > 1 {
|
||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||
"_fragment": "review_cards",
|
||||
"Reviews": reviews,
|
||||
"NextPage": query.page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"AnimeID": query.animeID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d/reviews", query.animeID),
|
||||
"Reviews": reviews,
|
||||
"NextPage": query.page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"AnimeID": query.animeID,
|
||||
"User": user,
|
||||
})
|
||||
}
|
||||
@@ -45,9 +45,9 @@ func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int,
|
||||
cacheKey := fmt.Sprintf("%d-%02d-%s", year, week, timezone)
|
||||
const ttl = 10 * time.Minute
|
||||
|
||||
h.scheduleCacheMu.Lock()
|
||||
h.Lock()
|
||||
cached, ok := h.scheduleCache[cacheKey]
|
||||
h.scheduleCacheMu.Unlock()
|
||||
h.Unlock()
|
||||
|
||||
if ok && time.Since(cached.fetchedAt) < ttl {
|
||||
return cached.value, nil
|
||||
@@ -58,9 +58,9 @@ func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int,
|
||||
return animeschedule.WeekSchedule{}, err
|
||||
}
|
||||
|
||||
h.scheduleCacheMu.Lock()
|
||||
h.Lock()
|
||||
h.scheduleCache[cacheKey] = cachedWeekSchedule{fetchedAt: time.Now(), value: value}
|
||||
h.scheduleCacheMu.Unlock()
|
||||
h.Unlock()
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
// Package anime provides anime catalog, discovery, search, and details services.
|
||||
// Package anime provides anime catalog, search, and details services.
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
@@ -76,355 +72,6 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (domain.DiscoverSectionData, error) {
|
||||
var res jikan.TopAnimeResult
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
switch section {
|
||||
case "Trending":
|
||||
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
|
||||
case "Upcoming":
|
||||
res, err = s.jikan.GetSeasonsUpcoming(gCtx, 1)
|
||||
case "Top":
|
||||
res, err = s.jikan.GetTopAnime(gCtx, 1)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return domain.DiscoverSectionData{}, err
|
||||
}
|
||||
|
||||
animes := wrapAnimes(res.Animes)
|
||||
if len(animes) > 8 {
|
||||
animes = animes[:8]
|
||||
}
|
||||
|
||||
return domain.DiscoverSectionData{
|
||||
Animes: animes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
return s.getTopPicksForYou(ctx, userID, forYouResultLimit)
|
||||
}
|
||||
|
||||
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
return s.getTopPicksForYou(ctx, userID, forYouFullResultLimit)
|
||||
}
|
||||
|
||||
func (s *animeService) getTopPicksForYou(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
resultLimit int,
|
||||
) (domain.CatalogSectionData, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
seedPool := buildRecommendationSeeds(now, watchlist)
|
||||
if len(seedPool) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
type rankedCandidate struct {
|
||||
id int
|
||||
collaborativeScore float64
|
||||
profileSearchScore float64
|
||||
anime jikan.Anime
|
||||
hasAnime bool
|
||||
}
|
||||
|
||||
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
|
||||
for _, entry := range watchlist {
|
||||
if entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
|
||||
}
|
||||
|
||||
candidatesByID := map[int]rankedCandidate{}
|
||||
var candidatesByIDMu sync.Mutex
|
||||
upsertCandidate := func(candidate rankedCandidate) {
|
||||
if candidate.id <= 0 {
|
||||
return
|
||||
}
|
||||
if _, exists := watchlistAnimeIDs[candidate.id]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
candidatesByIDMu.Lock()
|
||||
defer candidatesByIDMu.Unlock()
|
||||
|
||||
current, ok := candidatesByID[candidate.id]
|
||||
if !ok {
|
||||
candidatesByID[candidate.id] = candidate
|
||||
return
|
||||
}
|
||||
|
||||
current.collaborativeScore += candidate.collaborativeScore
|
||||
current.profileSearchScore += candidate.profileSearchScore
|
||||
if candidate.hasAnime {
|
||||
current.anime = candidate.anime
|
||||
current.hasAnime = true
|
||||
}
|
||||
candidatesByID[candidate.id] = current
|
||||
}
|
||||
|
||||
seedAnimes := make([]jikan.Anime, len(seedPool))
|
||||
var seedFetchGroup errgroup.Group
|
||||
seedFetchGroup.SetLimit(4)
|
||||
|
||||
for i, seed := range seedPool {
|
||||
seedFetchGroup.Go(func() error {
|
||||
anime, fetchErr := s.jikan.GetAnimeByID(ctx, seed.animeID)
|
||||
if fetchErr != nil {
|
||||
return fetchErr
|
||||
}
|
||||
seedAnimes[i] = anime
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := seedFetchGroup.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
profile := buildTasteProfile(now, seedPool, seedAnimes)
|
||||
|
||||
var recommendationGroup errgroup.Group
|
||||
recommendationGroup.SetLimit(4)
|
||||
|
||||
for _, seed := range seedPool {
|
||||
recommendationGroup.Go(func() error {
|
||||
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
||||
if recErr != nil {
|
||||
return recErr
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if i >= forYouMaxRecommendations {
|
||||
break
|
||||
}
|
||||
id := rec.Entry.MalID
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if id == seed.animeID {
|
||||
continue
|
||||
}
|
||||
upsertCandidate(rankedCandidate{
|
||||
id: id,
|
||||
collaborativeScore: float64(rec.Votes) * seed.weight,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := recommendationGroup.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
profileQueries := buildProfileSearchQueries(profile)
|
||||
var profileSearchGroup errgroup.Group
|
||||
profileSearchGroup.SetLimit(3)
|
||||
|
||||
for _, query := range profileQueries {
|
||||
profileSearchGroup.Go(func() error {
|
||||
res, searchErr := s.jikan.SearchAdvanced(
|
||||
ctx,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"score",
|
||||
"desc",
|
||||
query.genreIDs,
|
||||
query.studioID,
|
||||
true,
|
||||
1,
|
||||
forYouProfileSearchLimit,
|
||||
)
|
||||
if searchErr != nil {
|
||||
observability.Warn(
|
||||
"top_pick_profile_search_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"genres": query.genreIDs,
|
||||
"studio_id": query.studioID,
|
||||
},
|
||||
searchErr,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, anime := range res.Animes {
|
||||
if anime.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
upsertCandidate(rankedCandidate{
|
||||
id: anime.MalID,
|
||||
profileSearchScore: query.weight * profileSearchRankWeight(i),
|
||||
anime: anime,
|
||||
hasAnime: true,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := profileSearchGroup.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
if len(candidatesByID) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
rankedIDs := make([]rankedCandidate, 0, len(candidatesByID))
|
||||
for _, item := range candidatesByID {
|
||||
rankedIDs = append(rankedIDs, item)
|
||||
}
|
||||
sort.Slice(rankedIDs, func(i, j int) bool {
|
||||
left := rankedCandidateRetrievalScore(rankedIDs[i].collaborativeScore, rankedIDs[i].profileSearchScore)
|
||||
right := rankedCandidateRetrievalScore(rankedIDs[j].collaborativeScore, rankedIDs[j].profileSearchScore)
|
||||
if left == right {
|
||||
return rankedIDs[i].id < rankedIDs[j].id
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
|
||||
limit := min(len(rankedIDs), forYouCandidateFetchLimit)
|
||||
candidates := make([]recommendationCandidate, 0, limit)
|
||||
var candidatesMu sync.Mutex
|
||||
var detailGroup errgroup.Group
|
||||
detailGroup.SetLimit(6)
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
item := rankedIDs[i]
|
||||
detailGroup.Go(func() error {
|
||||
anime := item.anime
|
||||
if !item.hasAnime || !hasTasteMetadata(anime) {
|
||||
fetchedAnime, fetchErr := s.jikan.GetAnimeByID(ctx, item.id)
|
||||
if fetchErr != nil {
|
||||
observability.Warn(
|
||||
"recommendation_anime_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{"anime_id": item.id},
|
||||
fetchErr,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
anime = fetchedAnime
|
||||
}
|
||||
|
||||
candidate := scoreRecommendationCandidate(
|
||||
now,
|
||||
profile,
|
||||
anime,
|
||||
item.collaborativeScore,
|
||||
item.profileSearchScore,
|
||||
)
|
||||
candidatesMu.Lock()
|
||||
candidates = append(candidates, candidate)
|
||||
candidatesMu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := detailGroup.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].score == candidates[j].score {
|
||||
return candidates[i].anime.MalID < candidates[j].anime.MalID
|
||||
}
|
||||
return candidates[i].score > candidates[j].score
|
||||
})
|
||||
|
||||
return domain.CatalogSectionData{
|
||||
Animes: rerankRecommendationCandidates(candidates, resultLimit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return []domain.Anime{}, nil
|
||||
}
|
||||
|
||||
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int, 0, 50)
|
||||
for _, entry := range watchlist {
|
||||
status := strings.TrimSpace(entry.Status)
|
||||
if status != "watching" && status != "plan_to_watch" {
|
||||
continue
|
||||
}
|
||||
if !entry.Airing.Valid || !entry.Airing.Bool {
|
||||
continue
|
||||
}
|
||||
if entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, int(entry.AnimeID))
|
||||
if len(ids) >= 50 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return []domain.Anime{}, nil
|
||||
}
|
||||
|
||||
animes := make([]domain.Anime, 0, len(ids))
|
||||
var g errgroup.Group
|
||||
g.SetLimit(6)
|
||||
var mu sync.Mutex
|
||||
|
||||
for _, id := range ids {
|
||||
g.Go(func() error {
|
||||
anime, fetchErr := s.jikan.GetAnimeByID(ctx, id)
|
||||
if fetchErr != nil {
|
||||
return fetchErr
|
||||
}
|
||||
mu.Lock()
|
||||
animes = append(animes, domain.Anime{Anime: anime})
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, err
|
||||
}
|
||||
observability.Warn(
|
||||
"schedule_partial_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{"user_id": userID, "count": len(ids)},
|
||||
err,
|
||||
)
|
||||
return animes, nil
|
||||
}
|
||||
|
||||
return animes, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
|
||||
anime, err := s.jikan.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -525,8 +172,8 @@ func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
|
||||
return s.jikan.GetFullRelations(ctx, id)
|
||||
func (s *animeService) GetRelations(ctx context.Context, id int, mode jikan.WatchOrderMode) ([]jikan.RelationEntry, error) {
|
||||
return s.jikan.GetFullRelations(ctx, id, mode)
|
||||
}
|
||||
|
||||
func (s *animeService) WarmDetailSections(id int) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"mal/internal/config"
|
||||
"mal/internal/database"
|
||||
"mal/internal/episodes"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/playback"
|
||||
"mal/internal/server"
|
||||
"mal/internal/watchlist"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
|
||||
func NewApp() *fx.App {
|
||||
return fx.New(
|
||||
fx.WithLogger(observability.NewFxLogger),
|
||||
config.Module,
|
||||
database.Module,
|
||||
audit.Module,
|
||||
|
||||
@@ -2,6 +2,7 @@ package audit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
@@ -13,29 +14,9 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordInsertsAuditLog(t *testing.T) {
|
||||
tmp, err := os.CreateTemp("", "mal-audit-*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
_ = tmp.Close()
|
||||
t.Cleanup(func() { _ = os.Remove(tmp.Name()) })
|
||||
|
||||
sqlDB, err := db.Open(tmp.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
if err := database.RunMigrations(sqlDB); err != nil {
|
||||
t.Fatalf("RunMigrations: %v", err)
|
||||
}
|
||||
|
||||
queries := db.New(sqlDB)
|
||||
svc := audit.NewAuditService(queries)
|
||||
|
||||
if _, err := sqlDB.Exec("INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)", "user-1", "test", "hash"); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
sqlDB := openTestDB(t)
|
||||
svc := audit.NewAuditService(db.New(sqlDB))
|
||||
insertTestUser(t, sqlDB, "user-1")
|
||||
|
||||
ctx := audit.WithRequestInfo(context.Background(), "127.0.0.1", "unit-test")
|
||||
metadata, err := json.Marshal(struct {
|
||||
@@ -55,7 +36,54 @@ func TestRecordInsertsAuditLog(t *testing.T) {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
|
||||
rows, err := sqlDB.Query("SELECT action, resource_type, resource_id, ip, user_agent, metadata_json FROM audit_log WHERE user_id = ?", "user-1")
|
||||
auditRow := queryAuditRow(t, sqlDB, "user-1")
|
||||
assertAuditRow(t, auditRow)
|
||||
}
|
||||
|
||||
type auditRow struct {
|
||||
action string
|
||||
resourceType string
|
||||
resourceID string
|
||||
ip string
|
||||
userAgent string
|
||||
metadataJSON string
|
||||
}
|
||||
|
||||
func openTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
tmp, err := os.CreateTemp("", "mal-audit-*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
_ = tmp.Close()
|
||||
t.Cleanup(func() { _ = os.Remove(tmp.Name()) })
|
||||
|
||||
sqlDB, err := db.Open(tmp.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
if err := database.RunMigrations(sqlDB); err != nil {
|
||||
t.Fatalf("RunMigrations: %v", err)
|
||||
}
|
||||
|
||||
return sqlDB
|
||||
}
|
||||
|
||||
func insertTestUser(t *testing.T, sqlDB *sql.DB, userID string) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := sqlDB.ExecContext(context.Background(), "INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)", userID, "test", "hash"); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func queryAuditRow(t *testing.T, sqlDB *sql.DB, userID string) auditRow {
|
||||
t.Helper()
|
||||
|
||||
rows, err := sqlDB.QueryContext(context.Background(), "SELECT action, resource_type, resource_id, ip, user_agent, metadata_json FROM audit_log WHERE user_id = ?", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
@@ -65,18 +93,24 @@ func TestRecordInsertsAuditLog(t *testing.T) {
|
||||
t.Fatalf("expected audit row")
|
||||
}
|
||||
|
||||
var action, resourceType, resourceID, ip, userAgent, metadataJSON string
|
||||
if err := rows.Scan(&action, &resourceType, &resourceID, &ip, &userAgent, &metadataJSON); err != nil {
|
||||
var row auditRow
|
||||
if err := rows.Scan(&row.action, &row.resourceType, &row.resourceID, &row.ip, &row.userAgent, &row.metadataJSON); err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
|
||||
if action != "test_action" || resourceType != "thing" || resourceID != "123" {
|
||||
t.Fatalf("unexpected row action=%q resourceType=%q resourceID=%q", action, resourceType, resourceID)
|
||||
return row
|
||||
}
|
||||
|
||||
func assertAuditRow(t *testing.T, row auditRow) {
|
||||
t.Helper()
|
||||
|
||||
if row.action != "test_action" || row.resourceType != "thing" || row.resourceID != "123" {
|
||||
t.Fatalf("unexpected row action=%q resourceType=%q resourceID=%q", row.action, row.resourceType, row.resourceID)
|
||||
}
|
||||
if ip != "127.0.0.1" || userAgent != "unit-test" {
|
||||
t.Fatalf("unexpected request info ip=%q userAgent=%q", ip, userAgent)
|
||||
if row.ip != "127.0.0.1" || row.userAgent != "unit-test" {
|
||||
t.Fatalf("unexpected request info ip=%q userAgent=%q", row.ip, row.userAgent)
|
||||
}
|
||||
if metadataJSON == "" || metadataJSON == "null" {
|
||||
t.Fatalf("expected metadata_json, got %q", metadataJSON)
|
||||
if row.metadataJSON == "" || row.metadataJSON == "null" {
|
||||
t.Fatalf("expected metadata_json, got %q", row.metadataJSON)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,33 @@ func isPublicRequest(method string, path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func authenticateAPIRequest(c *gin.Context, svc domain.AuthService) (*domain.User, string, bool, error) {
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
token := strings.TrimSpace(authHeader[7:])
|
||||
user, err := svc.ValidateAPIToken(c.Request.Context(), token)
|
||||
return user, "", false, err
|
||||
}
|
||||
|
||||
sessionID, err := c.Cookie("session_id")
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
|
||||
user, err := svc.ValidateSession(c.Request.Context(), sessionID)
|
||||
return user, sessionID, true, err
|
||||
}
|
||||
|
||||
func authenticatePageRequest(c *gin.Context, svc domain.AuthService) (*domain.User, string, error) {
|
||||
sessionID, err := c.Cookie("session_id")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
user, err := svc.ValidateSession(c.Request.Context(), sessionID)
|
||||
return user, sessionID, err
|
||||
}
|
||||
|
||||
func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
@@ -65,18 +92,7 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
||||
|
||||
// API routes can authenticate via Bearer token OR cookie session.
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
token := strings.TrimSpace(authHeader[7:])
|
||||
user, err = svc.ValidateAPIToken(c.Request.Context(), token)
|
||||
} else if cookieSessionID, cookieErr := c.Cookie("session_id"); cookieErr == nil {
|
||||
sessionID = cookieSessionID
|
||||
usesCookieSession = true
|
||||
user, err = svc.ValidateSession(c.Request.Context(), sessionID)
|
||||
} else {
|
||||
err = cookieErr
|
||||
}
|
||||
|
||||
user, sessionID, usesCookieSession, err = authenticateAPIRequest(c, svc)
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
c.Abort()
|
||||
@@ -84,16 +100,8 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
||||
}
|
||||
} else {
|
||||
// Non-API routes only use cookie sessions and redirect to /login.
|
||||
cookieSessionID, cookieErr := c.Cookie("session_id")
|
||||
if cookieErr != nil {
|
||||
c.Redirect(http.StatusSeeOther, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
sessionID = cookieSessionID
|
||||
user, sessionID, err = authenticatePageRequest(c, svc)
|
||||
usesCookieSession = true
|
||||
user, err = svc.ValidateSession(c.Request.Context(), sessionID)
|
||||
if err != nil || user == nil {
|
||||
c.Redirect(http.StatusSeeOther, "/login")
|
||||
c.Abort()
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"mal/internal/domain"
|
||||
"mal/internal/server"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
@@ -13,9 +11,7 @@ var Module = fx.Options(
|
||||
NewAuthRepository,
|
||||
NewAuthService,
|
||||
NewAuthHandler,
|
||||
func(svc domain.AuthService) gin.HandlerFunc {
|
||||
return AuthMiddleware(svc)
|
||||
},
|
||||
AuthMiddleware,
|
||||
),
|
||||
fx.Provide(
|
||||
server.AsRouteRegister(func(h *AuthHandler) server.RouteRegister {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func DefaultAvatarURL(username string) string {
|
||||
seed := url.QueryEscape(strings.TrimSpace(username))
|
||||
return "https://api.dicebear.com/9.x/dylan/svg?seed=" + seed
|
||||
params := url.Values{}
|
||||
params.Set("seed", strings.TrimSpace(username))
|
||||
return "https://api.dicebear.com/9.x/dylan/svg?" + params.Encode()
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ func ProvideQueries(sqlDB *sql.DB) *db.Queries {
|
||||
|
||||
func RunMigrations(sqlDB *sql.DB) error {
|
||||
goose.SetBaseFS(migrationsFS)
|
||||
goose.SetLogger(goose.NopLogger())
|
||||
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
return fmt.Errorf("failed to set goose dialect: %w", err)
|
||||
@@ -48,6 +49,13 @@ func RunMigrations(sqlDB *sql.DB) error {
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
version, err := goose.GetDBVersion(sqlDB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get database migration version: %w", err)
|
||||
}
|
||||
|
||||
observability.Info("db_migrations_complete", "database", "", map[string]any{"version": version})
|
||||
|
||||
return nil
|
||||
}
|
||||
func RunMigrationsAndFixes(sqlDB *sql.DB) error {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
@@ -28,7 +29,7 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
|
||||
} {
|
||||
t.Run(indexName, func(t *testing.T) {
|
||||
var count int
|
||||
err := sqlDB.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, indexName).Scan(&count)
|
||||
err := sqlDB.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, indexName).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query index: %v", err)
|
||||
}
|
||||
|
||||
@@ -10,72 +10,82 @@ import (
|
||||
"mal/internal/observability"
|
||||
)
|
||||
|
||||
type animeDurationRow struct {
|
||||
id int64
|
||||
titleOriginal string
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Fix{
|
||||
ID: "20260608_backfill_anime_duration_seconds",
|
||||
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
|
||||
rows, err := sqlDB.QueryContext(ctx, `
|
||||
ID: "20260608_backfill_anime_duration_seconds",
|
||||
Apply: applyAnimeDurationSecondsBackfill,
|
||||
})
|
||||
}
|
||||
|
||||
func applyAnimeDurationSecondsBackfill(ctx context.Context, sqlDB *sql.DB) error {
|
||||
toUpdate, err := listAnimeMissingDurationSeconds(ctx, sqlDB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := jikan.NewClient(config.Config{}, db.New(sqlDB), observability.NewMetrics())
|
||||
for _, row := range toUpdate {
|
||||
anime, err := client.GetAnimeByID(ctx, int(row.id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch anime %d for duration backfill: %w", row.id, err)
|
||||
}
|
||||
|
||||
durationSeconds := anime.DurationSeconds()
|
||||
if durationSeconds <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := sqlDB.ExecContext(
|
||||
ctx,
|
||||
`UPDATE anime SET duration_seconds = ? WHERE id = ? AND duration_seconds IS NULL`,
|
||||
durationSeconds,
|
||||
row.id,
|
||||
); err != nil {
|
||||
return fmt.Errorf("update anime %d duration_seconds: %w", row.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func listAnimeMissingDurationSeconds(ctx context.Context, sqlDB *sql.DB) ([]animeDurationRow, error) {
|
||||
rows, err := sqlDB.QueryContext(ctx, `
|
||||
SELECT id, title_original, title_english, title_japanese, image_url, airing
|
||||
FROM anime
|
||||
WHERE duration_seconds IS NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query anime rows missing duration_seconds: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query anime rows missing duration_seconds: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
client := jikan.NewClient(config.Config{}, db.New(sqlDB), observability.NewMetrics())
|
||||
var toUpdate []animeDurationRow
|
||||
for rows.Next() {
|
||||
var row animeDurationRow
|
||||
var titleEnglish sql.NullString
|
||||
var titleJapanese sql.NullString
|
||||
var imageURL string
|
||||
var airing sql.NullBool
|
||||
if err := rows.Scan(
|
||||
&row.id,
|
||||
&row.titleOriginal,
|
||||
&titleEnglish,
|
||||
&titleJapanese,
|
||||
&imageURL,
|
||||
&airing,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan anime row missing duration_seconds: %w", err)
|
||||
}
|
||||
toUpdate = append(toUpdate, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate anime rows missing duration_seconds: %w", err)
|
||||
}
|
||||
|
||||
type animeRow struct {
|
||||
id int64
|
||||
titleOriginal string
|
||||
}
|
||||
|
||||
var toUpdate []animeRow
|
||||
for rows.Next() {
|
||||
var row animeRow
|
||||
var titleEnglish sql.NullString
|
||||
var titleJapanese sql.NullString
|
||||
var imageURL string
|
||||
var airing sql.NullBool
|
||||
if err := rows.Scan(
|
||||
&row.id,
|
||||
&row.titleOriginal,
|
||||
&titleEnglish,
|
||||
&titleJapanese,
|
||||
&imageURL,
|
||||
&airing,
|
||||
); err != nil {
|
||||
return fmt.Errorf("scan anime row missing duration_seconds: %w", err)
|
||||
}
|
||||
toUpdate = append(toUpdate, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("iterate anime rows missing duration_seconds: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range toUpdate {
|
||||
anime, err := client.GetAnimeByID(ctx, int(row.id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch anime %d for duration backfill: %w", row.id, err)
|
||||
}
|
||||
|
||||
durationSeconds := anime.DurationSeconds()
|
||||
if durationSeconds <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := sqlDB.ExecContext(
|
||||
ctx,
|
||||
`UPDATE anime SET duration_seconds = ? WHERE id = ? AND duration_seconds IS NULL`,
|
||||
durationSeconds,
|
||||
row.id,
|
||||
); err != nil {
|
||||
return fmt.Errorf("update anime %d duration_seconds: %w", row.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return toUpdate, nil
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ func (q *Queries) GetCommandPaletteContinueWatching(ctx context.Context, userID
|
||||
if userID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
limit = commandPaletteLimit(limit)
|
||||
|
||||
needle, pattern := commandPalettePattern(query)
|
||||
rows, err := q.db.QueryContext(ctx, `
|
||||
@@ -48,22 +46,8 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
||||
|
||||
items := make([]GetContinueWatchingEntriesRow, 0, int(limit))
|
||||
for rows.Next() {
|
||||
var item GetContinueWatchingEntriesRow
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.UserID,
|
||||
&item.AnimeID,
|
||||
&item.CurrentEpisode,
|
||||
&item.CurrentTimeSeconds,
|
||||
&item.DurationSeconds,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&item.TitleOriginal,
|
||||
&item.TitleEnglish,
|
||||
&item.TitleJapanese,
|
||||
&item.ImageUrl,
|
||||
&item.AnimeDurationSeconds,
|
||||
); err != nil {
|
||||
item, err := scanContinueWatchingEntry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
@@ -75,13 +59,31 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func scanContinueWatchingEntry(rows scanner) (GetContinueWatchingEntriesRow, error) {
|
||||
var item GetContinueWatchingEntriesRow
|
||||
err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.UserID,
|
||||
&item.AnimeID,
|
||||
&item.CurrentEpisode,
|
||||
&item.CurrentTimeSeconds,
|
||||
&item.DurationSeconds,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&item.TitleOriginal,
|
||||
&item.TitleEnglish,
|
||||
&item.TitleJapanese,
|
||||
&item.ImageUrl,
|
||||
&item.AnimeDurationSeconds,
|
||||
)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (q *Queries) GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]GetUserWatchListRow, error) {
|
||||
if userID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
limit = commandPaletteLimit(limit)
|
||||
|
||||
needle, pattern := commandPalettePattern(query)
|
||||
rows, err := q.db.QueryContext(ctx, `
|
||||
@@ -126,23 +128,8 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
||||
|
||||
items := make([]GetUserWatchListRow, 0, int(limit))
|
||||
for rows.Next() {
|
||||
var item GetUserWatchListRow
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.UserID,
|
||||
&item.AnimeID,
|
||||
&item.Status,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&item.CurrentEpisode,
|
||||
&item.LastEpisodeAt,
|
||||
&item.CurrentTimeSeconds,
|
||||
&item.TitleOriginal,
|
||||
&item.TitleEnglish,
|
||||
&item.TitleJapanese,
|
||||
&item.ImageUrl,
|
||||
&item.Airing,
|
||||
); err != nil {
|
||||
item, err := scanWatchListEntry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
@@ -154,7 +141,40 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func scanWatchListEntry(rows scanner) (GetUserWatchListRow, error) {
|
||||
var item GetUserWatchListRow
|
||||
err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.UserID,
|
||||
&item.AnimeID,
|
||||
&item.Status,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&item.CurrentEpisode,
|
||||
&item.LastEpisodeAt,
|
||||
&item.CurrentTimeSeconds,
|
||||
&item.TitleOriginal,
|
||||
&item.TitleEnglish,
|
||||
&item.TitleJapanese,
|
||||
&item.ImageUrl,
|
||||
&item.Airing,
|
||||
)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func commandPalettePattern(query string) (string, string) {
|
||||
needle := strings.ToLower(strings.TrimSpace(query))
|
||||
return needle, "%" + needle + "%"
|
||||
}
|
||||
|
||||
func commandPaletteLimit(limit int64) int64 {
|
||||
if limit <= 0 {
|
||||
return 5
|
||||
}
|
||||
|
||||
return limit
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func openCommandPaletteTestDB(t *testing.T) *sql.DB {
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
_, err = sqlDB.Exec(`
|
||||
_, err = sqlDB.ExecContext(context.Background(), `
|
||||
CREATE TABLE anime (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title_original TEXT NOT NULL,
|
||||
|
||||
@@ -11,9 +11,15 @@ func NullStringOr(n sql.NullString, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// DisplayTitle returns the English title, falling back to Japanese then original
|
||||
// DisplayTitle returns the English title, falling back to original then Japanese.
|
||||
func DisplayTitle(titleEnglish, titleJapanese sql.NullString, titleOriginal string) string {
|
||||
return NullStringOr(titleEnglish, NullStringOr(titleJapanese, titleOriginal))
|
||||
if titleEnglish.Valid && titleEnglish.String != "" {
|
||||
return titleEnglish.String
|
||||
}
|
||||
if titleOriginal != "" {
|
||||
return titleOriginal
|
||||
}
|
||||
return NullStringOr(titleJapanese, titleOriginal)
|
||||
}
|
||||
|
||||
func (r GetUserWatchListRow) DisplayTitle() string {
|
||||
|
||||
30
internal/db/helpers_test.go
Normal file
30
internal/db/helpers_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDisplayTitlePrefersOriginalBeforeJapanese(t *testing.T) {
|
||||
got := DisplayTitle(
|
||||
sql.NullString{},
|
||||
sql.NullString{String: "サイバーパンク エッジランナーズ", Valid: true},
|
||||
"Cyberpunk: Edgerunners",
|
||||
)
|
||||
|
||||
if got != "Cyberpunk: Edgerunners" {
|
||||
t.Fatalf("DisplayTitle() = %q, want original title", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayTitlePrefersEnglish(t *testing.T) {
|
||||
got := DisplayTitle(
|
||||
sql.NullString{String: "Frieren: Beyond Journey's End", Valid: true},
|
||||
sql.NullString{String: "葬送のフリーレン", Valid: true},
|
||||
"Sousou no Frieren",
|
||||
)
|
||||
|
||||
if got != "Frieren: Beyond Journey's End" {
|
||||
t.Fatalf("DisplayTitle() = %q, want English title", got)
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,6 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Note: we intentionally avoid naming this struct SkipSegmentOverride because
|
||||
// some environments may have an sqlc-generated SkipSegmentOverride model,
|
||||
// which would cause a redeclare build error.
|
||||
type SkipSegmentOverrideRow struct {
|
||||
ID string
|
||||
UserID string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
@@ -17,7 +18,7 @@ func Open(dbFile string) (*sql.DB, error) {
|
||||
return nil, fmt.Errorf("failed to open db: %w", err)
|
||||
}
|
||||
// WAL improves concurrency between readers and writers.
|
||||
_, _ = db.Exec("PRAGMA journal_mode=WAL;")
|
||||
_, _ = db.Exec("PRAGMA busy_timeout=5000;")
|
||||
_, _ = db.ExecContext(context.Background(), "PRAGMA journal_mode=WAL;")
|
||||
_, _ = db.ExecContext(context.Background(), "PRAGMA busy_timeout=5000;")
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestGetUserWatchlistAnimeIDsFiltersRequestedIDs(t *testing.T) {
|
||||
}
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
|
||||
_, err = sqlDB.Exec(`
|
||||
_, err = sqlDB.ExecContext(context.Background(), `
|
||||
CREATE TABLE watch_list_entry (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
|
||||
@@ -137,11 +137,6 @@ type AnimeCatalogService interface {
|
||||
GetTopPicksForYou(ctx context.Context, userID string) (CatalogSectionData, error)
|
||||
}
|
||||
|
||||
type AnimeDiscoverService interface {
|
||||
GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error)
|
||||
GetAiringSchedule(ctx context.Context, userID string) ([]Anime, error)
|
||||
}
|
||||
|
||||
type AnimeSearchService interface {
|
||||
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error)
|
||||
GetProducerNameByID(ctx context.Context, id int) (string, error)
|
||||
@@ -153,7 +148,7 @@ type AnimeDetailsService interface {
|
||||
GetAnimeByID(ctx context.Context, id int) (Anime, error)
|
||||
GetCharacters(ctx context.Context, id int) ([]CharacterEntry, error)
|
||||
GetRecommendations(ctx context.Context, id int) ([]RecommendationEntry, error)
|
||||
GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error)
|
||||
GetRelations(ctx context.Context, id int, mode jikan.WatchOrderMode) ([]jikan.RelationEntry, error)
|
||||
GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error)
|
||||
GetAllEpisodes(ctx context.Context, id int) ([]EpisodeData, error)
|
||||
GetRandomAnime(ctx context.Context) (Anime, error)
|
||||
@@ -180,17 +175,6 @@ func (d CatalogSectionData) TemplateFragment() string {
|
||||
return d.Fragment
|
||||
}
|
||||
|
||||
type DiscoverSectionData struct {
|
||||
Animes []Anime
|
||||
Section string
|
||||
WatchlistMap map[int64]bool
|
||||
Fragment string
|
||||
}
|
||||
|
||||
func (d DiscoverSectionData) TemplateFragment() string {
|
||||
return d.Fragment
|
||||
}
|
||||
|
||||
type AnimeRepository interface {
|
||||
GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error)
|
||||
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
|
||||
|
||||
@@ -49,6 +49,7 @@ type SubtitleItem struct {
|
||||
|
||||
type ModeSource struct {
|
||||
Token string `json:"token"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Subtitles []SubtitleItem `json:"subtitles"`
|
||||
Qualities []string `json:"qualities,omitempty"`
|
||||
}
|
||||
@@ -88,6 +89,8 @@ type EpisodeData struct {
|
||||
|
||||
type PlaybackRepository interface {
|
||||
InTx(ctx context.Context, fn func(ctx context.Context, repo PlaybackRepository) error) error
|
||||
UpsertAnime(ctx context.Context, params db.UpsertAnimeParams) (db.Anime, error)
|
||||
GetAnime(ctx context.Context, id int64) (db.Anime, error)
|
||||
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
|
||||
GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
|
||||
SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error
|
||||
|
||||
@@ -12,6 +12,7 @@ type StreamSource struct {
|
||||
type StreamResult struct {
|
||||
URL string
|
||||
Referer string
|
||||
Type string
|
||||
Subtitles []Subtitle
|
||||
Qualities []StreamSource
|
||||
}
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
package episodes
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
"mal/integrations/playback/allanime"
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
episodeService "mal/internal/episodes/service"
|
||||
"mal/internal/observability"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
@@ -21,9 +18,7 @@ var Module = fx.Options(
|
||||
fx.Provide(
|
||||
episodeAvailabilityEnabled,
|
||||
fx.Annotate(
|
||||
func(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, metrics *observability.Metrics) domain.EpisodeService {
|
||||
return episodeService.NewEpisodeService(queries, jikanClient, providers, enabled, metrics)
|
||||
},
|
||||
episodeService.NewEpisodeService,
|
||||
),
|
||||
),
|
||||
fx.Provide(func(p *allanime.AllAnimeProvider) []domain.EpisodeAvailabilityProvider {
|
||||
|
||||
250
internal/episodes/service/cache_store.go
Normal file
250
internal/episodes/service/cache_store.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
)
|
||||
|
||||
func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, source string, now time.Time, providerSuccess bool) (domain.CanonicalEpisodeList, error) {
|
||||
nextRefreshSQL := nextRefreshAt(anime, now)
|
||||
episodes := mergeEpisodes(jikanEpisodes, availability, anime.Episodes)
|
||||
payload := domain.CanonicalEpisodeList{
|
||||
AnimeID: anime.MalID,
|
||||
Episodes: episodes,
|
||||
Source: source,
|
||||
}
|
||||
if nextRefreshSQL.Valid {
|
||||
payload.NextRefreshAt = nextRefreshSQL.Time.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return domain.CanonicalEpisodeList{}, err
|
||||
}
|
||||
|
||||
if !s.writeEpisodeAvailabilityCache(ctx, anime, source, body, now, providerSuccess, nextRefreshSQL) {
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
observability.Info(
|
||||
"episodes_refresh_success",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"source": source,
|
||||
"episodes": len(episodes),
|
||||
"next_refresh": payload.NextRefreshAt,
|
||||
},
|
||||
)
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *EpisodeService) writeEpisodeAvailabilityCache(ctx context.Context, anime domain.Anime, source string, body []byte, now time.Time, providerSuccess bool, nextRefreshSQL sql.NullTime) bool {
|
||||
var retryUntil sql.NullTime
|
||||
if anime.Airing && providerSuccess {
|
||||
retryUntil = sql.NullTime{Time: nextRefreshSQL.Time.Add(retryWindow), Valid: nextRefreshSQL.Valid}
|
||||
}
|
||||
|
||||
err := s.queries.UpsertEpisodeAvailabilityCache(ctx, db.UpsertEpisodeAvailabilityCacheParams{
|
||||
AnimeID: int64(anime.MalID),
|
||||
Data: string(body),
|
||||
NextRefreshAt: nextRefreshSQL,
|
||||
RetryUntilAt: retryUntil,
|
||||
LastAttemptAt: sql.NullTime{Time: now, Valid: true},
|
||||
LastSuccessAt: sql.NullTime{Time: now, Valid: providerSuccess},
|
||||
FailureCount: 0,
|
||||
LastError: "",
|
||||
})
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
observability.Warn(
|
||||
"episodes_cache_write_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"source": source,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *EpisodeService) markFailure(ctx context.Context, anime domain.Anime, cause error) {
|
||||
now := s.clock.Now()
|
||||
next := nextRetryTime(anime, now)
|
||||
var retryUntil sql.NullTime
|
||||
nextBroadcast := nextBroadcastBeforeOrAt(anime, now)
|
||||
if !nextBroadcast.IsZero() {
|
||||
retryUntil = sql.NullTime{Time: nextBroadcast.Add(retryWindow), Valid: true}
|
||||
}
|
||||
|
||||
var nextSQL sql.NullTime
|
||||
if !next.IsZero() {
|
||||
nextSQL = sql.NullTime{Time: next, Valid: true}
|
||||
}
|
||||
|
||||
writeCtx := ctx
|
||||
if ctx.Err() != nil {
|
||||
var cancel context.CancelFunc
|
||||
writeCtx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
err := s.queries.MarkEpisodeAvailabilityRefreshFailed(writeCtx, db.MarkEpisodeAvailabilityRefreshFailedParams{
|
||||
LastAttemptAt: sql.NullTime{Time: now, Valid: true},
|
||||
LastError: truncate(cause.Error(), 400),
|
||||
NextRefreshAt: nextSQL,
|
||||
RetryUntilAt: retryUntil,
|
||||
AnimeID: int64(anime.MalID),
|
||||
})
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"episodes_mark_failure_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
observability.Warn(
|
||||
"episodes_refresh_failure_recorded",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"next_retry": next.Format(time.RFC3339),
|
||||
},
|
||||
cause,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *EpisodeService) getCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) {
|
||||
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID))
|
||||
if err != nil {
|
||||
s.metrics.ObserveCache("episode_availability", "miss")
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
var payload domain.CanonicalEpisodeList
|
||||
if err := json.Unmarshal([]byte(row.Data), &payload); err != nil {
|
||||
s.metrics.ObserveCache("episode_availability", "miss")
|
||||
observability.Warn(
|
||||
"episodes_cached_payload_invalid",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": animeID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
s.metrics.ObserveCache("episode_availability", "hit")
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, bool) {
|
||||
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
|
||||
if err != nil {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
|
||||
now := s.clock.Now()
|
||||
if !s.isFreshEpisodeCache(anime, row, now) {
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
|
||||
payload, ok := s.decodeFreshCachedPayload(anime, row.Data)
|
||||
if !ok {
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Info(
|
||||
"episodes_cached_payload_rejected",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"expected_count": anime.Episodes,
|
||||
"cached_episodes": len(payload.Episodes),
|
||||
},
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "hit")
|
||||
observability.Info(
|
||||
"episodes_cache_served",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"episodes": len(payload.Episodes),
|
||||
"next_refresh": payload.NextRefreshAt,
|
||||
},
|
||||
)
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func (s *EpisodeService) isFreshEpisodeCache(anime domain.Anime, row db.EpisodeAvailabilityCache, now time.Time) bool {
|
||||
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Info(
|
||||
"episodes_cache_due_for_refresh",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"next_refresh": row.NextRefreshAt.Time.Format(time.RFC3339),
|
||||
},
|
||||
)
|
||||
return false
|
||||
}
|
||||
if anime.Airing && row.UpdatedAt.Before(now.Add(-airingFallbackRefreshInterval)) {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Info(
|
||||
"episodes_cache_too_old_for_airing",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"updated_at": row.UpdatedAt.Format(time.RFC3339),
|
||||
},
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *EpisodeService) decodeFreshCachedPayload(anime domain.Anime, raw string) (domain.CanonicalEpisodeList, bool) {
|
||||
var payload domain.CanonicalEpisodeList
|
||||
err := json.Unmarshal([]byte(raw), &payload)
|
||||
if err == nil {
|
||||
return payload, true
|
||||
}
|
||||
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Warn(
|
||||
"episodes_cached_payload_invalid",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
133
internal/episodes/service/merge.go
Normal file
133
internal/episodes/service/merge.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
)
|
||||
|
||||
type episodePartial struct {
|
||||
title string
|
||||
filler bool
|
||||
recap bool
|
||||
sub bool
|
||||
dub bool
|
||||
}
|
||||
|
||||
func titleCandidates(anime domain.Anime) []string {
|
||||
out := []string{anime.Title}
|
||||
if anime.TitleEnglish != "" && anime.TitleEnglish != anime.Title {
|
||||
out = append(out, anime.TitleEnglish)
|
||||
}
|
||||
if anime.TitleJapanese != "" {
|
||||
out = append(out, anime.TitleJapanese)
|
||||
}
|
||||
for _, syn := range anime.TitleSynonyms {
|
||||
if syn != "" && syn != anime.Title && syn != anime.TitleEnglish && syn != anime.TitleJapanese {
|
||||
out = append(out, syn)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expectedCount int) bool {
|
||||
if expectedCount <= 0 {
|
||||
return true
|
||||
}
|
||||
if len(payload.Episodes) > expectedCount {
|
||||
return false
|
||||
}
|
||||
for _, episode := range payload.Episodes {
|
||||
if episode.Number <= 0 || episode.Number > expectedCount {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int) []domain.CanonicalEpisode {
|
||||
byNumber := map[int]episodePartial{}
|
||||
|
||||
for i, ep := range jikanEpisodes {
|
||||
if exceedsExpectedCount(i+1, expectedCount) {
|
||||
break
|
||||
}
|
||||
number, ok := jikanEpisodeNumber(ep, i)
|
||||
if !ok || exceedsExpectedCount(number, expectedCount) {
|
||||
continue
|
||||
}
|
||||
mergeEpisode(&byNumber, number, func(item *episodePartial) {
|
||||
item.title = strings.TrimSpace(ep.Title)
|
||||
item.filler = ep.Filler
|
||||
item.recap = ep.Recap
|
||||
})
|
||||
}
|
||||
mergeAvailability(&byNumber, availability.Sub, expectedCount, func(item *episodePartial) { item.sub = true })
|
||||
mergeAvailability(&byNumber, availability.Dub, expectedCount, func(item *episodePartial) { item.dub = true })
|
||||
|
||||
numbers := make([]int, 0, len(byNumber))
|
||||
for number := range byNumber {
|
||||
numbers = append(numbers, number)
|
||||
}
|
||||
sort.Ints(numbers)
|
||||
|
||||
episodes := make([]domain.CanonicalEpisode, 0, len(numbers))
|
||||
for _, number := range numbers {
|
||||
item := byNumber[number]
|
||||
title := item.title
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Episode %d", number)
|
||||
}
|
||||
episodes = append(episodes, domain.CanonicalEpisode{
|
||||
Number: number,
|
||||
Title: title,
|
||||
HasSub: item.sub,
|
||||
HasDub: item.dub,
|
||||
SubOnly: item.sub && !item.dub,
|
||||
Filler: item.filler,
|
||||
Recap: item.recap,
|
||||
})
|
||||
}
|
||||
return episodes
|
||||
}
|
||||
|
||||
func mergeEpisode(byNumber *map[int]episodePartial, number int, update func(*episodePartial)) {
|
||||
item := (*byNumber)[number]
|
||||
update(&item)
|
||||
(*byNumber)[number] = item
|
||||
}
|
||||
|
||||
func mergeAvailability(byNumber *map[int]episodePartial, numbers []int, expectedCount int, update func(*episodePartial)) {
|
||||
for _, number := range numbers {
|
||||
if number <= 0 || exceedsExpectedCount(number, expectedCount) {
|
||||
continue
|
||||
}
|
||||
mergeEpisode(byNumber, number, update)
|
||||
}
|
||||
}
|
||||
|
||||
func jikanEpisodeNumber(ep jikan.Episode, index int) (int, bool) {
|
||||
number, err := strconv.Atoi(strings.TrimSpace(ep.Episode))
|
||||
if err == nil && number > 0 {
|
||||
return number, true
|
||||
}
|
||||
if index < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return index + 1, true
|
||||
}
|
||||
|
||||
func exceedsExpectedCount(number int, expectedCount int) bool {
|
||||
return expectedCount > 0 && number > expectedCount
|
||||
}
|
||||
|
||||
func truncate(value string, maxLen int) string {
|
||||
if len(value) <= maxLen {
|
||||
return value
|
||||
}
|
||||
return value[:maxLen]
|
||||
}
|
||||
120
internal/episodes/service/provider_mapping.go
Normal file
120
internal/episodes/service/provider_mapping.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
)
|
||||
|
||||
func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, titles []string) (string, error) {
|
||||
providerID, found, err := s.cachedProviderID(ctx, anime, provider)
|
||||
if found || err != nil {
|
||||
return providerID, err
|
||||
}
|
||||
|
||||
providerID, err = provider.ResolveEpisodeProviderID(ctx, anime.MalID, titles)
|
||||
if err != nil {
|
||||
s.cacheProviderIDFailure(ctx, anime, provider, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.cacheProviderIDSuccess(ctx, anime, provider, providerID)
|
||||
observability.Info(
|
||||
"episodes_provider_id_resolved",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
"provider_id": providerID,
|
||||
},
|
||||
)
|
||||
return providerID, nil
|
||||
}
|
||||
|
||||
func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider) (string, bool, error) {
|
||||
row, err := s.queries.GetEpisodeProviderMapping(ctx, db.GetEpisodeProviderMappingParams{
|
||||
AnimeID: int64(anime.MalID),
|
||||
Provider: provider.Name(),
|
||||
})
|
||||
if err != nil {
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "miss")
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
observability.Warn(
|
||||
"episodes_provider_id_cache_read_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
},
|
||||
err,
|
||||
)
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
if row.FailedUntil.Valid && row.FailedUntil.Time.After(s.clock.Now()) {
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "hit")
|
||||
return "", true, fmt.Errorf("cached provider mapping failure active until %s: %s", row.FailedUntil.Time.Format(time.RFC3339), row.LastError)
|
||||
}
|
||||
if strings.TrimSpace(row.ProviderShowID) == "" {
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "miss")
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "hit")
|
||||
observability.Info(
|
||||
"episodes_provider_id_cache_hit",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
"provider_id": row.ProviderShowID,
|
||||
},
|
||||
)
|
||||
return row.ProviderShowID, true, nil
|
||||
}
|
||||
|
||||
func (s *EpisodeService) cacheProviderIDFailure(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, resolveErr error) {
|
||||
_ = s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
|
||||
AnimeID: int64(anime.MalID),
|
||||
Provider: provider.Name(),
|
||||
ProviderShowID: "",
|
||||
FailedUntil: sql.NullTime{Time: s.clock.Now().Add(time.Hour), Valid: true},
|
||||
LastError: truncate(resolveErr.Error(), 400),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *EpisodeService) cacheProviderIDSuccess(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, providerID string) {
|
||||
err := s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
|
||||
AnimeID: int64(anime.MalID),
|
||||
Provider: provider.Name(),
|
||||
ProviderShowID: providerID,
|
||||
FailedUntil: sql.NullTime{},
|
||||
LastError: "",
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
observability.Warn(
|
||||
"episodes_provider_id_cache_write_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
131
internal/episodes/service/refresh_policy.go
Normal file
131
internal/episodes/service/refresh_policy.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
)
|
||||
|
||||
const (
|
||||
retryInterval = 15 * time.Minute
|
||||
retryWindow = 3 * time.Hour
|
||||
airingFallbackRefreshInterval = 6 * time.Hour
|
||||
)
|
||||
|
||||
func nextRefreshAt(anime domain.Anime, now time.Time) sql.NullTime {
|
||||
if !anime.Airing {
|
||||
return sql.NullTime{}
|
||||
}
|
||||
|
||||
// During the hours immediately following a broadcast time, providers can lag.
|
||||
// Keep retrying for a short window, even if the provider request succeeded.
|
||||
lastBroadcast := nextBroadcastBeforeOrAt(anime, now)
|
||||
if !lastBroadcast.IsZero() && now.Before(lastBroadcast.Add(retryWindow)) {
|
||||
return sql.NullTime{Time: now.Add(retryInterval).UTC(), Valid: true}
|
||||
}
|
||||
|
||||
next := nextBroadcastAfter(anime, now)
|
||||
if !next.IsZero() {
|
||||
return sql.NullTime{Time: next, Valid: true}
|
||||
}
|
||||
|
||||
// Broadcast metadata is often missing or wrong for currently airing shows.
|
||||
// Avoid "never refresh again" caches by falling back to a fixed interval.
|
||||
return sql.NullTime{Time: now.Add(airingFallbackRefreshInterval).UTC(), Valid: true}
|
||||
}
|
||||
|
||||
func nextRetryTime(anime domain.Anime, now time.Time) time.Time {
|
||||
broadcast := nextBroadcastBeforeOrAt(anime, now)
|
||||
if broadcast.IsZero() || now.After(broadcast.Add(retryWindow)) {
|
||||
return nextBroadcastAfter(anime, now)
|
||||
}
|
||||
return now.Add(retryInterval)
|
||||
}
|
||||
|
||||
func nextBroadcastBeforeOrAt(anime domain.Anime, now time.Time) time.Time {
|
||||
next := nextBroadcastAfter(anime, now.AddDate(0, 0, -7))
|
||||
if next.IsZero() || next.After(now) {
|
||||
return time.Time{}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func nextBroadcastAfter(anime domain.Anime, after time.Time) time.Time {
|
||||
day := weekdayFromJikan(anime.Broadcast.Day)
|
||||
if day < 0 || strings.TrimSpace(anime.Broadcast.Time) == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
loc := time.UTC
|
||||
if tz := strings.TrimSpace(anime.Broadcast.Timezone); tz != "" {
|
||||
if loaded, err := time.LoadLocation(tz); err == nil {
|
||||
loc = loaded
|
||||
} else {
|
||||
observability.Warn(
|
||||
"episodes_broadcast_timezone_parse_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"timezone": tz,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
hour, minute, ok := parseBroadcastTime(anime.Broadcast.Time)
|
||||
if !ok {
|
||||
observability.Warn(
|
||||
"episodes_broadcast_time_parse_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"time": anime.Broadcast.Time,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
localAfter := after.In(loc)
|
||||
daysAhead := (int(day) - int(localAfter.Weekday()) + 7) % 7
|
||||
candidate := time.Date(localAfter.Year(), localAfter.Month(), localAfter.Day()+daysAhead, hour, minute, 0, 0, loc)
|
||||
if !candidate.After(localAfter) {
|
||||
candidate = candidate.AddDate(0, 0, 7)
|
||||
}
|
||||
return candidate.UTC()
|
||||
}
|
||||
|
||||
func weekdayFromJikan(day string) time.Weekday {
|
||||
switch strings.ToLower(strings.TrimSpace(day)) {
|
||||
case "sundays":
|
||||
return time.Sunday
|
||||
case "mondays":
|
||||
return time.Monday
|
||||
case "tuesdays":
|
||||
return time.Tuesday
|
||||
case "wednesdays":
|
||||
return time.Wednesday
|
||||
case "thursdays":
|
||||
return time.Thursday
|
||||
case "fridays":
|
||||
return time.Friday
|
||||
case "saturdays":
|
||||
return time.Saturday
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
func parseBroadcastTime(value string) (int, int, bool) {
|
||||
t, err := time.Parse("15:04", strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return t.Hour(), t.Minute(), true
|
||||
}
|
||||
@@ -3,24 +3,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
retryInterval = 15 * time.Minute
|
||||
retryWindow = 3 * time.Hour
|
||||
airingFallbackRefreshInterval = 6 * time.Hour
|
||||
)
|
||||
|
||||
type Clock interface {
|
||||
@@ -229,337 +218,6 @@ func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime do
|
||||
return domain.EpisodeAvailability{}, "", fmt.Errorf("no episode availability provider matched anime_id=%d", anime.MalID)
|
||||
}
|
||||
|
||||
func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, titles []string) (string, error) {
|
||||
row, err := s.queries.GetEpisodeProviderMapping(ctx, db.GetEpisodeProviderMappingParams{
|
||||
AnimeID: int64(anime.MalID),
|
||||
Provider: provider.Name(),
|
||||
})
|
||||
if err == nil {
|
||||
if row.FailedUntil.Valid && row.FailedUntil.Time.After(s.clock.Now()) {
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "hit")
|
||||
return "", fmt.Errorf("cached provider mapping failure active until %s: %s", row.FailedUntil.Time.Format(time.RFC3339), row.LastError)
|
||||
}
|
||||
if strings.TrimSpace(row.ProviderShowID) != "" {
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "hit")
|
||||
observability.Info(
|
||||
"episodes_provider_id_cache_hit",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
"provider_id": row.ProviderShowID,
|
||||
},
|
||||
)
|
||||
return row.ProviderShowID, nil
|
||||
}
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "miss")
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "miss")
|
||||
observability.Warn(
|
||||
"episodes_provider_id_cache_read_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
},
|
||||
err,
|
||||
)
|
||||
} else {
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "miss")
|
||||
}
|
||||
|
||||
providerID, err := provider.ResolveEpisodeProviderID(ctx, anime.MalID, titles)
|
||||
if err != nil {
|
||||
_ = s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
|
||||
AnimeID: int64(anime.MalID),
|
||||
Provider: provider.Name(),
|
||||
ProviderShowID: "",
|
||||
FailedUntil: sql.NullTime{Time: s.clock.Now().Add(time.Hour), Valid: true},
|
||||
LastError: truncate(err.Error(), 400),
|
||||
})
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
|
||||
AnimeID: int64(anime.MalID),
|
||||
Provider: provider.Name(),
|
||||
ProviderShowID: providerID,
|
||||
FailedUntil: sql.NullTime{},
|
||||
LastError: "",
|
||||
})
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"episodes_provider_id_cache_write_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
observability.Info(
|
||||
"episodes_provider_id_resolved",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
"provider_id": providerID,
|
||||
},
|
||||
)
|
||||
return providerID, nil
|
||||
}
|
||||
|
||||
func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, source string, now time.Time, providerSuccess bool) (domain.CanonicalEpisodeList, error) {
|
||||
var nextRefreshSQL sql.NullTime
|
||||
if anime.Airing {
|
||||
// During the hours immediately following a broadcast time, providers can lag.
|
||||
// Keep retrying for a short window, even if the provider request succeeded.
|
||||
lastBroadcast := nextBroadcastBeforeOrAt(anime, now)
|
||||
if !lastBroadcast.IsZero() && now.Before(lastBroadcast.Add(retryWindow)) {
|
||||
nextRefreshSQL = sql.NullTime{Time: now.Add(retryInterval).UTC(), Valid: true}
|
||||
} else {
|
||||
next := nextBroadcastAfter(anime, now)
|
||||
if !next.IsZero() {
|
||||
nextRefreshSQL = sql.NullTime{Time: next, Valid: true}
|
||||
} else {
|
||||
// Broadcast metadata is often missing or wrong for currently airing shows.
|
||||
// Avoid "never refresh again" caches by falling back to a fixed interval.
|
||||
nextRefreshSQL = sql.NullTime{Time: now.Add(airingFallbackRefreshInterval).UTC(), Valid: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
episodes := mergeEpisodes(jikanEpisodes, availability, anime.Episodes)
|
||||
payload := domain.CanonicalEpisodeList{
|
||||
AnimeID: anime.MalID,
|
||||
Episodes: episodes,
|
||||
Source: source,
|
||||
}
|
||||
if nextRefreshSQL.Valid {
|
||||
payload.NextRefreshAt = nextRefreshSQL.Time.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return domain.CanonicalEpisodeList{}, err
|
||||
}
|
||||
|
||||
var retryUntil sql.NullTime
|
||||
if anime.Airing && providerSuccess {
|
||||
retryUntil = sql.NullTime{Time: nextRefreshSQL.Time.Add(retryWindow), Valid: nextRefreshSQL.Valid}
|
||||
}
|
||||
|
||||
err = s.queries.UpsertEpisodeAvailabilityCache(ctx, db.UpsertEpisodeAvailabilityCacheParams{
|
||||
AnimeID: int64(anime.MalID),
|
||||
Data: string(body),
|
||||
NextRefreshAt: nextRefreshSQL,
|
||||
RetryUntilAt: retryUntil,
|
||||
LastAttemptAt: sql.NullTime{Time: now, Valid: true},
|
||||
LastSuccessAt: sql.NullTime{Time: now, Valid: providerSuccess},
|
||||
FailureCount: 0,
|
||||
LastError: "",
|
||||
})
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"episodes_cache_write_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"source": source,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
observability.Info(
|
||||
"episodes_refresh_success",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"source": source,
|
||||
"episodes": len(episodes),
|
||||
"next_refresh": payload.NextRefreshAt,
|
||||
},
|
||||
)
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *EpisodeService) markFailure(ctx context.Context, anime domain.Anime, cause error) {
|
||||
now := s.clock.Now()
|
||||
next := nextRetryTime(anime, now)
|
||||
var retryUntil sql.NullTime
|
||||
nextBroadcast := nextBroadcastBeforeOrAt(anime, now)
|
||||
if !nextBroadcast.IsZero() {
|
||||
retryUntil = sql.NullTime{Time: nextBroadcast.Add(retryWindow), Valid: true}
|
||||
}
|
||||
|
||||
var nextSQL sql.NullTime
|
||||
if !next.IsZero() {
|
||||
nextSQL = sql.NullTime{Time: next, Valid: true}
|
||||
}
|
||||
|
||||
writeCtx := ctx
|
||||
if ctx.Err() != nil {
|
||||
var cancel context.CancelFunc
|
||||
writeCtx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
err := s.queries.MarkEpisodeAvailabilityRefreshFailed(writeCtx, db.MarkEpisodeAvailabilityRefreshFailedParams{
|
||||
LastAttemptAt: sql.NullTime{Time: now, Valid: true},
|
||||
LastError: truncate(cause.Error(), 400),
|
||||
NextRefreshAt: nextSQL,
|
||||
RetryUntilAt: retryUntil,
|
||||
AnimeID: int64(anime.MalID),
|
||||
})
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"episodes_mark_failure_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
observability.Warn(
|
||||
"episodes_refresh_failure_recorded",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"next_retry": next.Format(time.RFC3339),
|
||||
},
|
||||
cause,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *EpisodeService) getCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) {
|
||||
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID))
|
||||
if err != nil {
|
||||
s.metrics.ObserveCache("episode_availability", "miss")
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
var payload domain.CanonicalEpisodeList
|
||||
if err := json.Unmarshal([]byte(row.Data), &payload); err != nil {
|
||||
s.metrics.ObserveCache("episode_availability", "miss")
|
||||
observability.Warn(
|
||||
"episodes_cached_payload_invalid",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": animeID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
s.metrics.ObserveCache("episode_availability", "hit")
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, bool) {
|
||||
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
|
||||
if err != nil {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
|
||||
now := s.clock.Now()
|
||||
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Info(
|
||||
"episodes_cache_due_for_refresh",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"next_refresh": row.NextRefreshAt.Time.Format(time.RFC3339),
|
||||
},
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
|
||||
if anime.Airing && row.UpdatedAt.Before(now.Add(-airingFallbackRefreshInterval)) {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Info(
|
||||
"episodes_cache_too_old_for_airing",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"updated_at": row.UpdatedAt.Format(time.RFC3339),
|
||||
},
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
|
||||
var payload domain.CanonicalEpisodeList
|
||||
if err := json.Unmarshal([]byte(row.Data), &payload); err != nil {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Warn(
|
||||
"episodes_cached_payload_invalid",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Info(
|
||||
"episodes_cached_payload_rejected",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"expected_count": anime.Episodes,
|
||||
"cached_episodes": len(payload.Episodes),
|
||||
},
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "hit")
|
||||
observability.Info(
|
||||
"episodes_cache_served",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"episodes": len(payload.Episodes),
|
||||
"next_refresh": payload.NextRefreshAt,
|
||||
},
|
||||
)
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expectedCount int) bool {
|
||||
if expectedCount <= 0 {
|
||||
return true
|
||||
}
|
||||
if len(payload.Episodes) > expectedCount {
|
||||
return false
|
||||
}
|
||||
for _, episode := range payload.Episodes {
|
||||
if episode.Number <= 0 || episode.Number > expectedCount {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *EpisodeService) jikanOnly(ctx context.Context, anime domain.Anime, source string) (domain.CanonicalEpisodeList, error) {
|
||||
episodes, err := s.jikan.GetAllEpisodes(ctx, anime.MalID)
|
||||
if err != nil {
|
||||
@@ -571,201 +229,3 @@ func (s *EpisodeService) jikanOnly(ctx context.Context, anime domain.Anime, sour
|
||||
Source: source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func titleCandidates(anime domain.Anime) []string {
|
||||
out := []string{anime.Title}
|
||||
if anime.TitleEnglish != "" && anime.TitleEnglish != anime.Title {
|
||||
out = append(out, anime.TitleEnglish)
|
||||
}
|
||||
if anime.TitleJapanese != "" {
|
||||
out = append(out, anime.TitleJapanese)
|
||||
}
|
||||
for _, syn := range anime.TitleSynonyms {
|
||||
if syn != "" && syn != anime.Title && syn != anime.TitleEnglish && syn != anime.TitleJapanese {
|
||||
out = append(out, syn)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int) []domain.CanonicalEpisode {
|
||||
type partial struct {
|
||||
title string
|
||||
filler bool
|
||||
recap bool
|
||||
sub bool
|
||||
dub bool
|
||||
}
|
||||
byNumber := map[int]partial{}
|
||||
|
||||
for i, ep := range jikanEpisodes {
|
||||
if expectedCount > 0 && i >= expectedCount {
|
||||
break
|
||||
}
|
||||
number, ok := jikanEpisodeNumber(ep, i)
|
||||
if !ok || exceedsExpectedCount(number, expectedCount) {
|
||||
continue
|
||||
}
|
||||
item := byNumber[number]
|
||||
item.title = strings.TrimSpace(ep.Title)
|
||||
item.filler = ep.Filler
|
||||
item.recap = ep.Recap
|
||||
byNumber[number] = item
|
||||
}
|
||||
for _, n := range availability.Sub {
|
||||
if n <= 0 || exceedsExpectedCount(n, expectedCount) {
|
||||
continue
|
||||
}
|
||||
item := byNumber[n]
|
||||
item.sub = true
|
||||
byNumber[n] = item
|
||||
}
|
||||
for _, n := range availability.Dub {
|
||||
if n <= 0 || exceedsExpectedCount(n, expectedCount) {
|
||||
continue
|
||||
}
|
||||
item := byNumber[n]
|
||||
item.dub = true
|
||||
byNumber[n] = item
|
||||
}
|
||||
|
||||
numbers := make([]int, 0, len(byNumber))
|
||||
for number := range byNumber {
|
||||
numbers = append(numbers, number)
|
||||
}
|
||||
sort.Ints(numbers)
|
||||
|
||||
episodes := make([]domain.CanonicalEpisode, 0, len(numbers))
|
||||
for _, number := range numbers {
|
||||
item := byNumber[number]
|
||||
title := item.title
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Episode %d", number)
|
||||
}
|
||||
episodes = append(episodes, domain.CanonicalEpisode{
|
||||
Number: number,
|
||||
Title: title,
|
||||
HasSub: item.sub,
|
||||
HasDub: item.dub,
|
||||
SubOnly: item.sub && !item.dub,
|
||||
Filler: item.filler,
|
||||
Recap: item.recap,
|
||||
})
|
||||
}
|
||||
return episodes
|
||||
}
|
||||
|
||||
func jikanEpisodeNumber(ep jikan.Episode, index int) (int, bool) {
|
||||
number, err := strconv.Atoi(strings.TrimSpace(ep.Episode))
|
||||
if err == nil && number > 0 {
|
||||
return number, true
|
||||
}
|
||||
if index < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return index + 1, true
|
||||
}
|
||||
|
||||
func exceedsExpectedCount(number int, expectedCount int) bool {
|
||||
return expectedCount > 0 && number > expectedCount
|
||||
}
|
||||
|
||||
func nextRetryTime(anime domain.Anime, now time.Time) time.Time {
|
||||
broadcast := nextBroadcastBeforeOrAt(anime, now)
|
||||
if broadcast.IsZero() || now.After(broadcast.Add(retryWindow)) {
|
||||
return nextBroadcastAfter(anime, now)
|
||||
}
|
||||
return now.Add(retryInterval)
|
||||
}
|
||||
|
||||
func nextBroadcastBeforeOrAt(anime domain.Anime, now time.Time) time.Time {
|
||||
next := nextBroadcastAfter(anime, now.AddDate(0, 0, -7))
|
||||
if next.IsZero() || next.After(now) {
|
||||
return time.Time{}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func nextBroadcastAfter(anime domain.Anime, after time.Time) time.Time {
|
||||
day := weekdayFromJikan(anime.Broadcast.Day)
|
||||
if day < 0 || strings.TrimSpace(anime.Broadcast.Time) == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
loc := time.UTC
|
||||
if tz := strings.TrimSpace(anime.Broadcast.Timezone); tz != "" {
|
||||
if loaded, err := time.LoadLocation(tz); err == nil {
|
||||
loc = loaded
|
||||
} else {
|
||||
observability.Warn(
|
||||
"episodes_broadcast_timezone_parse_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"timezone": tz,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
hour, minute, ok := parseBroadcastTime(anime.Broadcast.Time)
|
||||
if !ok {
|
||||
observability.Warn(
|
||||
"episodes_broadcast_time_parse_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"time": anime.Broadcast.Time,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
localAfter := after.In(loc)
|
||||
daysAhead := (int(day) - int(localAfter.Weekday()) + 7) % 7
|
||||
candidate := time.Date(localAfter.Year(), localAfter.Month(), localAfter.Day()+daysAhead, hour, minute, 0, 0, loc)
|
||||
if !candidate.After(localAfter) {
|
||||
candidate = candidate.AddDate(0, 0, 7)
|
||||
}
|
||||
return candidate.UTC()
|
||||
}
|
||||
|
||||
func weekdayFromJikan(day string) time.Weekday {
|
||||
switch strings.ToLower(strings.TrimSpace(day)) {
|
||||
case "sundays":
|
||||
return time.Sunday
|
||||
case "mondays":
|
||||
return time.Monday
|
||||
case "tuesdays":
|
||||
return time.Tuesday
|
||||
case "wednesdays":
|
||||
return time.Wednesday
|
||||
case "thursdays":
|
||||
return time.Thursday
|
||||
case "fridays":
|
||||
return time.Friday
|
||||
case "saturdays":
|
||||
return time.Saturday
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
func parseBroadcastTime(value string) (int, int, bool) {
|
||||
t, err := time.Parse("15:04", strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return t.Hour(), t.Minute(), true
|
||||
}
|
||||
|
||||
func truncate(value string, maxLen int) string {
|
||||
if len(value) <= maxLen {
|
||||
return value
|
||||
}
|
||||
return value[:maxLen]
|
||||
}
|
||||
|
||||
60
internal/observability/fx.go
Normal file
60
internal/observability/fx.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"go.uber.org/fx/fxevent"
|
||||
)
|
||||
|
||||
type fxLogger struct{}
|
||||
|
||||
func NewFxLogger() fxevent.Logger {
|
||||
return fxLogger{}
|
||||
}
|
||||
|
||||
func (fxLogger) LogEvent(event fxevent.Event) {
|
||||
eventName, fields, err := describeFXEventError(event)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
Error(eventName, "fx", "", fields, err)
|
||||
}
|
||||
|
||||
func describeFXEventError(event fxevent.Event) (string, map[string]any, error) {
|
||||
if ok, eventName, fields, err := describeFXExecutionEventError(event); ok {
|
||||
return eventName, fields, err
|
||||
}
|
||||
|
||||
return describeFXLifecycleEventError(event)
|
||||
}
|
||||
|
||||
func describeFXExecutionEventError(event fxevent.Event) (bool, string, map[string]any, error) {
|
||||
switch e := event.(type) {
|
||||
case *fxevent.Provided:
|
||||
return true, "fx_provide_failed", map[string]any{"constructor": e.ConstructorName}, e.Err
|
||||
case *fxevent.Invoked:
|
||||
return true, "fx_invoke_failed", map[string]any{"function": e.FunctionName}, e.Err
|
||||
case *fxevent.Run:
|
||||
return true, "fx_run_failed", map[string]any{"function": e.Name, "kind": e.Kind}, e.Err
|
||||
case *fxevent.OnStartExecuted:
|
||||
return true, "fx_on_start_failed", map[string]any{"caller": e.CallerName, "function": e.FunctionName, "runtime": e.Runtime}, e.Err
|
||||
case *fxevent.OnStopExecuted:
|
||||
return true, "fx_on_stop_failed", map[string]any{"caller": e.CallerName, "function": e.FunctionName, "runtime": e.Runtime}, e.Err
|
||||
default:
|
||||
return false, "", nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func describeFXLifecycleEventError(event fxevent.Event) (string, map[string]any, error) {
|
||||
switch e := event.(type) {
|
||||
case *fxevent.Started:
|
||||
return "fx_start_failed", nil, e.Err
|
||||
case *fxevent.Stopped:
|
||||
return "fx_stop_failed", nil, e.Err
|
||||
case *fxevent.RollingBack:
|
||||
return "fx_rollback_start", nil, e.StartErr
|
||||
case *fxevent.RolledBack:
|
||||
return "fx_rollback_failed", nil, e.Err
|
||||
default:
|
||||
return "", nil, nil
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package observability
|
||||
|
||||
import "context"
|
||||
|
||||
// Small helpers to keep logging consistent and low-friction across the codebase.
|
||||
|
||||
func Info(event string, component string, message string, fields map[string]any) {
|
||||
@@ -10,6 +12,14 @@ func Warn(event string, component string, message string, fields map[string]any,
|
||||
LogJSON(LogLevelWarn, event, component, message, fields, err)
|
||||
}
|
||||
|
||||
func WarnContext(ctx context.Context, event string, component string, message string, fields map[string]any, err error) {
|
||||
LogContext(ctx, LogLevelWarn, event, component, message, fields, err)
|
||||
}
|
||||
|
||||
func Error(event string, component string, message string, fields map[string]any, err error) {
|
||||
LogJSON(LogLevelError, event, component, message, fields, err)
|
||||
}
|
||||
|
||||
func ErrorContext(ctx context.Context, event string, component string, message string, fields map[string]any, err error) {
|
||||
LogContext(ctx, LogLevelError, event, component, message, fields, err)
|
||||
}
|
||||
|
||||
35
internal/observability/helpers_test.go
Normal file
35
internal/observability/helpers_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWarnEnrichesSourceAndErrorContext(t *testing.T) {
|
||||
fields := enrichFields(LogLevelWarn, map[string]any{"anime_id": 123}, wrappedError())
|
||||
|
||||
if fields["anime_id"] != 123 {
|
||||
t.Fatalf("expected existing field to survive, got %#v", fields["anime_id"])
|
||||
}
|
||||
|
||||
source, ok := fields["source"].(string)
|
||||
if !ok || source == "" {
|
||||
t.Fatalf("expected source field, got %#v", fields["source"])
|
||||
}
|
||||
|
||||
errorType, ok := fields["error_type"].(string)
|
||||
if !ok || errorType == "" {
|
||||
t.Fatalf("expected error_type field, got %#v", fields["error_type"])
|
||||
}
|
||||
|
||||
chain, ok := fields["error_chain"].(string)
|
||||
if !ok || !strings.Contains(chain, "query anime") || !strings.Contains(chain, "db timeout") {
|
||||
t.Fatalf("expected wrapped error chain, got %#v", fields["error_chain"])
|
||||
}
|
||||
}
|
||||
|
||||
func wrappedError() error {
|
||||
return fmt.Errorf("query anime: %w", errors.New("db timeout"))
|
||||
}
|
||||
@@ -2,11 +2,33 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ansiReset = "\x1b[0m"
|
||||
ansiBlue = "\x1b[36m"
|
||||
ansiStatusBlue = "\x1b[34m"
|
||||
ansiGreen = "\x1b[32m"
|
||||
ansiYellow = "\x1b[33m"
|
||||
ansiOrange = "\x1b[38;5;208m"
|
||||
ansiRed = "\x1b[31m"
|
||||
)
|
||||
|
||||
var colorLogs = shouldColorLogs()
|
||||
|
||||
type LogLevel string
|
||||
|
||||
const (
|
||||
@@ -25,11 +47,17 @@ type LogEvent struct {
|
||||
Component string `json:"component,omitempty"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
log.SetFlags(0)
|
||||
}
|
||||
|
||||
func LogJSON(level LogLevel, event string, component string, message string, fields map[string]any, err error) {
|
||||
errorValue := ""
|
||||
if err != nil {
|
||||
errorValue = err.Error()
|
||||
}
|
||||
LogContext(context.TODO(), level, event, component, message, fields, err)
|
||||
}
|
||||
|
||||
func LogContext(ctx context.Context, level LogLevel, event string, component string, message string, fields map[string]any, err error) {
|
||||
fields = enrichFields(level, fields, err)
|
||||
fields = enrichRequestFields(ctx, fields)
|
||||
|
||||
entry := LogEvent{
|
||||
TS: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
@@ -37,23 +65,410 @@ func LogJSON(level LogLevel, event string, component string, message string, fie
|
||||
Event: event,
|
||||
Message: message,
|
||||
Fields: fields,
|
||||
Error: errorValue,
|
||||
Component: component,
|
||||
}
|
||||
|
||||
// Best-effort. If encoding fails, fall back to a minimal line.
|
||||
bytes, marshalErr := json.Marshal(entry)
|
||||
if marshalErr != nil {
|
||||
// Keep output JSON-only even on failures by constructing a minimal entry.
|
||||
// Marshal individual strings to ensure proper escaping.
|
||||
tsBytes, _ := json.Marshal(time.Now().UTC().Format(time.RFC3339Nano))
|
||||
levelBytes, _ := json.Marshal(level)
|
||||
eventBytes, _ := json.Marshal("log_marshal_failed")
|
||||
componentBytes, _ := json.Marshal(component)
|
||||
errBytes, _ := json.Marshal(marshalErr.Error())
|
||||
log.Printf(`{"ts":%s,"level":%s,"event":%s,"component":%s,"error":%s}`, tsBytes, levelBytes, eventBytes, componentBytes, errBytes)
|
||||
if err != nil {
|
||||
entry.Error = err.Error()
|
||||
}
|
||||
|
||||
log.Print(formatLogEntry(entry))
|
||||
}
|
||||
|
||||
func enrichRequestFields(ctx context.Context, fields map[string]any) map[string]any {
|
||||
requestContext, ok := RequestContextFromContext(ctx)
|
||||
if !ok {
|
||||
return fields
|
||||
}
|
||||
|
||||
enriched := cloneFields(fields)
|
||||
if enriched == nil {
|
||||
enriched = make(map[string]any, 3)
|
||||
}
|
||||
|
||||
if requestContext.ID != "" {
|
||||
if _, exists := enriched["request_id"]; !exists {
|
||||
enriched["request_id"] = requestContext.ID
|
||||
}
|
||||
}
|
||||
if requestContext.Path != "" {
|
||||
if _, exists := enriched["request_path"]; !exists {
|
||||
enriched["request_path"] = requestContext.Path
|
||||
}
|
||||
}
|
||||
if requestContext.Route != "" && requestContext.Route != requestContext.Path {
|
||||
if _, exists := enriched["request_route"]; !exists {
|
||||
enriched["request_route"] = requestContext.Route
|
||||
}
|
||||
}
|
||||
|
||||
return enriched
|
||||
}
|
||||
|
||||
func enrichFields(level LogLevel, fields map[string]any, err error) map[string]any {
|
||||
if level == LogLevelInfo {
|
||||
return fields
|
||||
}
|
||||
|
||||
enriched := cloneFields(fields)
|
||||
if enriched == nil {
|
||||
enriched = make(map[string]any, 3)
|
||||
}
|
||||
|
||||
if _, exists := enriched["source"]; !exists {
|
||||
if source := callerSource(); source != "" {
|
||||
enriched["source"] = source
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if _, exists := enriched["error_type"]; !exists {
|
||||
if errorType := formatErrorType(err); errorType != "" {
|
||||
enriched["error_type"] = errorType
|
||||
}
|
||||
}
|
||||
if _, exists := enriched["error_chain"]; !exists {
|
||||
if chain := formatErrorChain(err); chain != "" {
|
||||
enriched["error_chain"] = chain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return enriched
|
||||
}
|
||||
|
||||
func callerSource() string {
|
||||
pcs := make([]uintptr, 8)
|
||||
n := runtime.Callers(3, pcs)
|
||||
frames := runtime.CallersFrames(pcs[:n])
|
||||
|
||||
for {
|
||||
frame, more := frames.Next()
|
||||
if !strings.Contains(frame.File, "/internal/observability/") {
|
||||
return filepath.Base(frame.File) + ":" + strconv.Itoa(frame.Line)
|
||||
}
|
||||
if !more {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatErrorType(err error) string {
|
||||
errType := reflect.TypeOf(err)
|
||||
if errType == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return errType.String()
|
||||
}
|
||||
|
||||
func formatErrorChain(err error) string {
|
||||
parts := make([]string, 0, 4)
|
||||
for current := err; current != nil; current = errors.Unwrap(current) {
|
||||
parts = append(parts, current.Error())
|
||||
if len(parts) == 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) <= 1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.Join(parts, " -> ")
|
||||
}
|
||||
|
||||
func formatLogEntry(entry LogEvent) string {
|
||||
if entry.Event == "http_request" {
|
||||
return formatHTTPRequestLog(entry)
|
||||
}
|
||||
|
||||
parts := []string{entry.TS, formatLogLevel(entry.Level), entry.Event}
|
||||
|
||||
if entry.Component != "" {
|
||||
parts = append(parts, "component="+entry.Component)
|
||||
}
|
||||
|
||||
if entry.Message != "" {
|
||||
parts = append(parts, quoteIfNeeded(entry.Message))
|
||||
}
|
||||
|
||||
if len(entry.Fields) > 0 {
|
||||
keys := make([]string, 0, len(entry.Fields))
|
||||
for key := range entry.Fields {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+formatFieldValue(entry.Fields[key]))
|
||||
}
|
||||
}
|
||||
|
||||
if entry.Error != "" {
|
||||
parts = append(parts, "error="+quoteIfNeeded(entry.Error))
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func formatHTTPRequestLog(entry LogEvent) string {
|
||||
fields := cloneFields(entry.Fields)
|
||||
status := popField(fields, "status")
|
||||
method := popField(fields, "method")
|
||||
path := popField(fields, "path")
|
||||
duration := popField(fields, "duration_ms")
|
||||
bytes := popField(fields, "bytes")
|
||||
route := popField(fields, "route")
|
||||
query := popField(fields, "query")
|
||||
clientIP := popField(fields, "client_ip")
|
||||
|
||||
parts := []string{entry.TS, formatLogLevel(entry.Level), "http"}
|
||||
appendNonEmpty(&parts, status)
|
||||
appendNonEmpty(&parts, strings.TrimSpace(method+" "+path))
|
||||
appendNonEmpty(&parts, duration)
|
||||
appendNonEmpty(&parts, bytes)
|
||||
appendKeyValue(&parts, "route", route)
|
||||
appendKeyValueQuoted(&parts, "query", query)
|
||||
appendClientIP(&parts, clientIP)
|
||||
appendSortedFields(&parts, fields)
|
||||
|
||||
if entry.Error != "" {
|
||||
parts = append(parts, "error="+quoteIfNeeded(entry.Error))
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func appendNonEmpty(parts *[]string, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
|
||||
log.Print(string(bytes))
|
||||
*parts = append(*parts, value)
|
||||
}
|
||||
|
||||
func appendKeyValue(parts *[]string, key string, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
|
||||
*parts = append(*parts, key+"="+value)
|
||||
}
|
||||
|
||||
func appendKeyValueQuoted(parts *[]string, key string, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
|
||||
*parts = append(*parts, key+"="+quoteIfNeeded(value))
|
||||
}
|
||||
|
||||
func appendClientIP(parts *[]string, clientIP string) {
|
||||
if clientIP == "" || isLocalClientIP(clientIP) {
|
||||
return
|
||||
}
|
||||
|
||||
*parts = append(*parts, "ip="+clientIP)
|
||||
}
|
||||
|
||||
func appendSortedFields(parts *[]string, fields map[string]any) {
|
||||
if len(fields) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(fields))
|
||||
for key := range fields {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
*parts = append(*parts, key+"="+formatFieldValue(fields[key]))
|
||||
}
|
||||
}
|
||||
|
||||
func cloneFields(fields map[string]any) map[string]any {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
copyFields := make(map[string]any, len(fields))
|
||||
for key, value := range fields {
|
||||
copyFields[key] = value
|
||||
}
|
||||
|
||||
return copyFields
|
||||
}
|
||||
|
||||
func popField(fields map[string]any, key string) string {
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, ok := fields[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
delete(fields, key)
|
||||
|
||||
return formatInlineField(key, value)
|
||||
}
|
||||
|
||||
func formatInlineField(key string, value any) string {
|
||||
switch key {
|
||||
case "status":
|
||||
return formatHTTPStatus(value)
|
||||
case "duration_ms":
|
||||
return formatDurationMillis(value)
|
||||
case "bytes":
|
||||
return formatBytes(value)
|
||||
default:
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
}
|
||||
|
||||
func formatHTTPStatus(value any) string {
|
||||
status := fmt.Sprint(value)
|
||||
if !colorLogs || status == "" {
|
||||
return status
|
||||
}
|
||||
|
||||
switch status[0] {
|
||||
case '1':
|
||||
return ansiStatusBlue + status + ansiReset
|
||||
case '2':
|
||||
return ansiGreen + status + ansiReset
|
||||
case '3':
|
||||
return ansiYellow + status + ansiReset
|
||||
case '4':
|
||||
return ansiOrange + status + ansiReset
|
||||
case '5':
|
||||
return ansiRed + status + ansiReset
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
func formatDurationMillis(value any) string {
|
||||
ms, ok := toFloat64(value)
|
||||
if !ok {
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
return strconv.FormatFloat(ms, 'f', -1, 64) + "ms"
|
||||
}
|
||||
|
||||
func formatBytes(value any) string {
|
||||
bytesValue, ok := toFloat64(value)
|
||||
if !ok {
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
if bytesValue < 1024 {
|
||||
return strconv.FormatFloat(bytesValue, 'f', -1, 64) + "B"
|
||||
}
|
||||
if bytesValue < 1024*1024 {
|
||||
return strconv.FormatFloat(bytesValue/1024, 'f', 1, 64) + "KB"
|
||||
}
|
||||
|
||||
return strconv.FormatFloat(bytesValue/(1024*1024), 'f', 1, 64) + "MB"
|
||||
}
|
||||
|
||||
func toFloat64(value any) (float64, bool) {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int32:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case float64:
|
||||
return v, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func isLocalClientIP(value string) bool {
|
||||
parsed := net.ParseIP(value)
|
||||
if parsed == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return parsed.IsLoopback()
|
||||
}
|
||||
|
||||
func formatLogLevel(level LogLevel) string {
|
||||
if colorLogs {
|
||||
switch level {
|
||||
case LogLevelWarn:
|
||||
return ansiYellow + "WARN" + ansiReset
|
||||
case LogLevelError:
|
||||
return ansiRed + "ERROR" + ansiReset
|
||||
default:
|
||||
return ansiBlue + "INFO" + ansiReset
|
||||
}
|
||||
}
|
||||
|
||||
switch level {
|
||||
case LogLevelWarn:
|
||||
return "WARN"
|
||||
case LogLevelError:
|
||||
return "ERROR"
|
||||
default:
|
||||
return "INFO"
|
||||
}
|
||||
}
|
||||
|
||||
func shouldColorLogs() bool {
|
||||
if strings.TrimSpace(os.Getenv("NO_COLOR")) != "" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("TERM")), "dumb") {
|
||||
return false
|
||||
}
|
||||
|
||||
info, err := os.Stderr.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return info.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
func formatFieldValue(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return quoteIfNeeded(v)
|
||||
case time.Duration:
|
||||
return v.String()
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(v), 'f', -1, 32)
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case fmt.Stringer:
|
||||
return quoteIfNeeded(v.String())
|
||||
default:
|
||||
return quoteIfNeeded(fmt.Sprint(value))
|
||||
}
|
||||
}
|
||||
|
||||
func quoteIfNeeded(value string) string {
|
||||
if value == "" {
|
||||
return `""`
|
||||
}
|
||||
|
||||
for _, r := range value {
|
||||
if r == '=' || r == ' ' || r == '\t' || r == '\n' || r == '"' {
|
||||
return strconv.Quote(value)
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
60
internal/observability/log_test.go
Normal file
60
internal/observability/log_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatLogEntryFormatsHTTPRequestCompactly(t *testing.T) {
|
||||
line := formatLogEntry(LogEvent{
|
||||
TS: "2026-06-11T12:57:39.557972Z",
|
||||
Level: LogLevelInfo,
|
||||
Event: "http_request",
|
||||
Fields: map[string]any{
|
||||
"bytes": 56198,
|
||||
"client_ip": "127.0.0.1",
|
||||
"duration_ms": 9.419,
|
||||
"method": "GET",
|
||||
"path": "/api/catalog/top-pick",
|
||||
"status": 200,
|
||||
},
|
||||
})
|
||||
|
||||
checks := []string{
|
||||
"2026-06-11T12:57:39.557972Z INFO http 200 GET /api/catalog/top-pick 9.419ms 54.9KB",
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(line, check) {
|
||||
t.Fatalf("line %q missing %q", line, check)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(line, "client_ip=") {
|
||||
t.Fatalf("line should omit loopback ip: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatHTTPStatusColorsByStatusFamily(t *testing.T) {
|
||||
previousColorLogs := colorLogs
|
||||
colorLogs = true
|
||||
t.Cleanup(func() {
|
||||
colorLogs = previousColorLogs
|
||||
})
|
||||
|
||||
tests := map[any]string{
|
||||
101: ansiStatusBlue + "101" + ansiReset,
|
||||
200: ansiGreen + "200" + ansiReset,
|
||||
302: ansiYellow + "302" + ansiReset,
|
||||
404: ansiOrange + "404" + ansiReset,
|
||||
500: ansiRed + "500" + ansiReset,
|
||||
"unknown": "unknown",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
got := formatHTTPStatus(input)
|
||||
if got != want {
|
||||
t.Fatalf("formatHTTPStatus(%v) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -17,7 +18,7 @@ func TestMetricsHandlerRendersPrometheusFamilies(t *testing.T) {
|
||||
metrics.ObserveCache("jikan", "hit")
|
||||
metrics.ObserveCache("episode_availability", "miss")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
metrics.Handler().ServeHTTP(rec, req)
|
||||
|
||||
|
||||
32
internal/observability/request.go
Normal file
32
internal/observability/request.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package observability
|
||||
|
||||
import "context"
|
||||
|
||||
type requestContextKey struct{}
|
||||
|
||||
type RequestContext struct {
|
||||
ID string
|
||||
Path string
|
||||
Route string
|
||||
}
|
||||
|
||||
func WithRequestContext(ctx context.Context, requestID string, path string, route string) context.Context {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return context.WithValue(ctx, requestContextKey{}, RequestContext{
|
||||
ID: requestID,
|
||||
Path: path,
|
||||
Route: route,
|
||||
})
|
||||
}
|
||||
|
||||
func RequestContextFromContext(ctx context.Context) (RequestContext, bool) {
|
||||
if ctx == nil {
|
||||
return RequestContext{}, false
|
||||
}
|
||||
|
||||
requestContext, ok := ctx.Value(requestContextKey{}).(RequestContext)
|
||||
return requestContext, ok
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
@@ -37,7 +39,6 @@ func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimePlaybac
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -302,6 +303,10 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
|
||||
|
||||
resp, err := h.streamingClient.Do(req)
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
observability.ErrorContext(c.Request.Context(), "proxy_stream_upstream_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
|
||||
_ = c.Error(err).SetType(gin.ErrorTypePrivate)
|
||||
}
|
||||
c.Status(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
@@ -310,11 +315,15 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
|
||||
if isHLSPlaylistResponse(targetURL, resp.Header) {
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
|
||||
if err != nil {
|
||||
observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_read_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
|
||||
_ = c.Error(err).SetType(gin.ErrorTypePrivate)
|
||||
c.Status(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
rewritten, err := h.rewriteHLSPlaylist(string(body), targetURL, referer)
|
||||
if err != nil {
|
||||
observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_rewrite_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
|
||||
_ = c.Error(err).SetType(gin.ErrorTypePrivate)
|
||||
c.Status(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
@@ -415,7 +424,9 @@ func (h *PlaybackHandler) proxyPlaylistURI(rawURI string, baseURL *url.URL, refe
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "/watch/proxy/stream?token=" + url.QueryEscape(token), nil
|
||||
params := url.Values{}
|
||||
params.Set("token", token)
|
||||
return "/watch/proxy/stream?" + params.Encode(), nil
|
||||
}
|
||||
|
||||
func copyProxyHeaders(dst http.Header, src http.Header) {
|
||||
@@ -482,6 +493,10 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
|
||||
|
||||
resp, err := h.proxyClient.Do(req)
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
observability.ErrorContext(c.Request.Context(), "proxy_subtitle_upstream_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
|
||||
_ = c.Error(err).SetType(gin.ErrorTypePrivate)
|
||||
}
|
||||
c.Status(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
@@ -489,6 +504,8 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
|
||||
if err != nil {
|
||||
observability.ErrorContext(c.Request.Context(), "proxy_subtitle_read_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
|
||||
_ = c.Error(err).SetType(gin.ErrorTypePrivate)
|
||||
c.Status(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
"mal/integrations/playback/allanime"
|
||||
"mal/internal/config"
|
||||
"mal/internal/domain"
|
||||
@@ -18,14 +17,8 @@ func provideProxyTokenKey(cfg config.Config) ProxyTokenKey {
|
||||
var Module = fx.Options(
|
||||
fx.Provide(
|
||||
NewPlaybackRepository,
|
||||
fx.Annotate(
|
||||
func(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, episodeSvc domain.EpisodeService, auditSvc domain.AuditService, proxyTokenKey ProxyTokenKey) domain.PlaybackService {
|
||||
return NewPlaybackService(repo, providers, jikan, episodeSvc, auditSvc, proxyTokenKey)
|
||||
},
|
||||
),
|
||||
func(svc domain.PlaybackService, animeSvc domain.AnimePlaybackService) *handler.PlaybackHandler {
|
||||
return handler.NewPlaybackHandler(svc, animeSvc)
|
||||
},
|
||||
NewPlaybackService,
|
||||
handler.NewPlaybackHandler,
|
||||
),
|
||||
fx.Provide(
|
||||
server.AsRouteRegister(func(h *handler.PlaybackHandler) server.RouteRegister {
|
||||
|
||||
193
internal/playback/progress.go
Normal file
193
internal/playback/progress.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
)
|
||||
|
||||
func (s *playbackService) loadWatchProgress(ctx context.Context, userID string, animeID int, totalEpisodes int, episode string) (float64, string, []int64) {
|
||||
if userID == "" {
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: int64(animeID),
|
||||
})
|
||||
|
||||
watchlistStatus := ""
|
||||
var watchlistIDs []int64
|
||||
startTime := 0.0
|
||||
if err == nil {
|
||||
watchlistStatus = entry.Status
|
||||
watchlistIDs = []int64{entry.AnimeID}
|
||||
if resumeTimeForEpisode(entry.CurrentEpisode, entry.CurrentTimeSeconds, totalEpisodes, episode) > 0 {
|
||||
startTime = entry.CurrentTimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
if startTime > 0 {
|
||||
return startTime, watchlistStatus, watchlistIDs
|
||||
}
|
||||
|
||||
cwEntry, err := s.repo.GetContinueWatchingEntry(ctx, db.GetContinueWatchingEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: int64(animeID),
|
||||
})
|
||||
if err == nil {
|
||||
startTime = resumeTimeForEpisode(cwEntry.CurrentEpisode, cwEntry.CurrentTimeSeconds, totalEpisodes, episode)
|
||||
}
|
||||
|
||||
return startTime, watchlistStatus, watchlistIDs
|
||||
}
|
||||
|
||||
func resumeTimeForEpisode(currentEpisode sql.NullInt64, currentTimeSeconds float64, totalEpisodes int, requestedEpisode string) float64 {
|
||||
if !currentEpisode.Valid {
|
||||
return 0
|
||||
}
|
||||
|
||||
if strconv.FormatInt(currentEpisode.Int64, 10) == requestedEpisode {
|
||||
return currentTimeSeconds
|
||||
}
|
||||
|
||||
if totalEpisodes > 0 && requestedEpisode == strconv.Itoa(totalEpisodes) && currentEpisode.Int64 == int64(totalEpisodes) {
|
||||
return currentTimeSeconds
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *playbackService) CompleteAnime(ctx context.Context, userID string, animeID int64) error {
|
||||
if err := s.repo.InTx(ctx, func(txCtx context.Context, repo domain.PlaybackRepository) error {
|
||||
entry, err := repo.GetWatchListEntry(txCtx, db.GetWatchListEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
})
|
||||
if err != nil || entry.Status != "completed" {
|
||||
_, err = repo.UpsertWatchListEntry(txCtx, db.UpsertWatchListEntryParams{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
Status: "completed",
|
||||
CurrentEpisode: entry.CurrentEpisode,
|
||||
CurrentTimeSeconds: entry.CurrentTimeSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: userID,
|
||||
Action: "watch_completed",
|
||||
ResourceType: "anime",
|
||||
ResourceID: strconv.FormatInt(animeID, 10),
|
||||
}); err != nil {
|
||||
observability.Warn(
|
||||
"audit_record_failed",
|
||||
"playback",
|
||||
"",
|
||||
map[string]any{"user_id": userID, "anime_id": animeID, "action": "watch_completed"},
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playbackService) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error {
|
||||
err := s.repo.InTx(ctx, func(txCtx context.Context, repo domain.PlaybackRepository) error {
|
||||
if _, err := repo.GetAnime(txCtx, animeID); err != nil {
|
||||
if _, err := repo.UpsertAnime(txCtx, minimalAnimeParams(animeID)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err := repo.UpsertContinueWatchingEntry(txCtx, db.UpsertContinueWatchingEntryParams{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
CurrentEpisode: sql.NullInt64{Int64: int64(episode), Valid: true},
|
||||
CurrentTimeSeconds: timeSeconds,
|
||||
DurationSeconds: sql.NullFloat64{Valid: false},
|
||||
})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
metadataBytes, marshalErr := json.Marshal(struct {
|
||||
Episode int `json:"episode"`
|
||||
TimeSeconds float64 `json:"time_seconds"`
|
||||
}{Episode: episode, TimeSeconds: timeSeconds})
|
||||
if marshalErr == nil {
|
||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: userID,
|
||||
Action: "watch_progress_saved",
|
||||
ResourceType: "anime",
|
||||
ResourceID: strconv.FormatInt(animeID, 10),
|
||||
MetadataJSON: metadataBytes,
|
||||
})
|
||||
} else {
|
||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: userID,
|
||||
Action: "watch_progress_saved",
|
||||
ResourceType: "anime",
|
||||
ResourceID: strconv.FormatInt(animeID, 10),
|
||||
})
|
||||
}
|
||||
observability.Info("watch_progress_saved", "playback", "", map[string]any{
|
||||
"anime_id": animeID,
|
||||
"episode": episode,
|
||||
"time_seconds": timeSeconds,
|
||||
"user_id": userID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playbackService) ensureAnimeRow(ctx context.Context, anime domain.Anime) {
|
||||
if _, err := s.repo.GetAnime(ctx, int64(anime.MalID)); err == nil {
|
||||
return
|
||||
}
|
||||
_, _ = s.repo.UpsertAnime(ctx, animeParams(anime))
|
||||
}
|
||||
|
||||
func animeParams(anime domain.Anime) db.UpsertAnimeParams {
|
||||
durationSeconds := anime.DurationSeconds()
|
||||
duration := sql.NullFloat64{Valid: durationSeconds > 0}
|
||||
if duration.Valid {
|
||||
duration.Float64 = durationSeconds
|
||||
}
|
||||
|
||||
return db.UpsertAnimeParams{
|
||||
ID: int64(anime.MalID),
|
||||
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},
|
||||
DurationSeconds: duration,
|
||||
}
|
||||
}
|
||||
|
||||
func minimalAnimeParams(animeID int64) db.UpsertAnimeParams {
|
||||
return db.UpsertAnimeParams{
|
||||
ID: animeID,
|
||||
TitleOriginal: fmt.Sprintf("Anime %d", animeID),
|
||||
Airing: sql.NullBool{Valid: false},
|
||||
}
|
||||
}
|
||||
69
internal/playback/proxy_tokens.go
Normal file
69
internal/playback/proxy_tokens.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type proxyTokenTarget struct {
|
||||
targetURL string
|
||||
referer string
|
||||
scope string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type proxyTokenStore struct {
|
||||
mu sync.Mutex
|
||||
tokens map[string]proxyTokenTarget
|
||||
}
|
||||
|
||||
func newProxyTokenStore() *proxyTokenStore {
|
||||
return &proxyTokenStore{
|
||||
tokens: make(map[string]proxyTokenTarget),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *proxyTokenStore) create(targetURL, referer, scope string, ttl time.Duration, now time.Time) (string, error) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", fmt.Errorf("generate proxy token: %w", err)
|
||||
}
|
||||
|
||||
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneExpiredLocked(now)
|
||||
s.tokens[token] = proxyTokenTarget{
|
||||
targetURL: targetURL,
|
||||
referer: referer,
|
||||
scope: scope,
|
||||
expiresAt: now.Add(ttl),
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *proxyTokenStore) resolve(token string, now time.Time) (proxyTokenTarget, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
target, ok := s.tokens[token]
|
||||
if !ok {
|
||||
return proxyTokenTarget{}, fmt.Errorf("invalid proxy token")
|
||||
}
|
||||
if !target.expiresAt.After(now) {
|
||||
delete(s.tokens, token)
|
||||
return proxyTokenTarget{}, fmt.Errorf("proxy token expired")
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (s *proxyTokenStore) pruneExpiredLocked(now time.Time) {
|
||||
for token, target := range s.tokens {
|
||||
if !target.expiresAt.After(now) {
|
||||
delete(s.tokens, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,14 @@ func (r *playbackRepository) InTx(ctx context.Context, fn func(ctx context.Conte
|
||||
}, fn)
|
||||
}
|
||||
|
||||
func (r *playbackRepository) UpsertAnime(ctx context.Context, params db.UpsertAnimeParams) (db.Anime, error) {
|
||||
return r.queries.UpsertAnime(ctx, params)
|
||||
}
|
||||
|
||||
func (r *playbackRepository) GetAnime(ctx context.Context, id int64) (db.Anime, error) {
|
||||
return r.queries.GetAnime(ctx, id)
|
||||
}
|
||||
|
||||
func (r *playbackRepository) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) {
|
||||
return r.queries.GetWatchListEntry(ctx, params)
|
||||
}
|
||||
|
||||
@@ -3,26 +3,12 @@ package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type playbackService struct {
|
||||
@@ -38,66 +24,6 @@ type playbackService struct {
|
||||
|
||||
type ProxyTokenKey string
|
||||
|
||||
type proxyTokenTarget struct {
|
||||
targetURL string
|
||||
referer string
|
||||
scope string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type proxyTokenStore struct {
|
||||
mu sync.Mutex
|
||||
tokens map[string]proxyTokenTarget
|
||||
}
|
||||
|
||||
func newProxyTokenStore() *proxyTokenStore {
|
||||
return &proxyTokenStore{
|
||||
tokens: make(map[string]proxyTokenTarget),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *proxyTokenStore) create(targetURL, referer, scope string, ttl time.Duration, now time.Time) (string, error) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", fmt.Errorf("generate proxy token: %w", err)
|
||||
}
|
||||
|
||||
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneExpiredLocked(now)
|
||||
s.tokens[token] = proxyTokenTarget{
|
||||
targetURL: targetURL,
|
||||
referer: referer,
|
||||
scope: scope,
|
||||
expiresAt: now.Add(ttl),
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *proxyTokenStore) resolve(token string, now time.Time) (proxyTokenTarget, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
target, ok := s.tokens[token]
|
||||
if !ok {
|
||||
return proxyTokenTarget{}, fmt.Errorf("invalid proxy token")
|
||||
}
|
||||
if !target.expiresAt.After(now) {
|
||||
delete(s.tokens, token)
|
||||
return proxyTokenTarget{}, fmt.Errorf("proxy token expired")
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (s *proxyTokenStore) pruneExpiredLocked(now time.Time) {
|
||||
for token, target := range s.tokens {
|
||||
if !target.expiresAt.After(now) {
|
||||
delete(s.tokens, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, episodes domain.EpisodeService, auditSvc domain.AuditService, proxyTokenKey ProxyTokenKey) domain.PlaybackService {
|
||||
return &playbackService{
|
||||
repo: repo,
|
||||
@@ -132,408 +58,11 @@ func (s *playbackService) ResolveProxyToken(token string, scope string) (string,
|
||||
return target.targetURL, target.referer, nil
|
||||
}
|
||||
|
||||
func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (domain.WatchPageData, error) {
|
||||
// 1. Get Anime details for total episodes and titles
|
||||
anime, err := s.jikan.GetAnimeByID(ctx, animeID)
|
||||
if err != nil {
|
||||
return domain.WatchPageData{}, fmt.Errorf("failed to fetch anime: %w", err)
|
||||
}
|
||||
|
||||
// 2. Resolve streams from providers
|
||||
searchTitles := []string{anime.Title}
|
||||
if anime.TitleEnglish != "" && anime.TitleEnglish != anime.Title {
|
||||
searchTitles = append(searchTitles, anime.TitleEnglish)
|
||||
}
|
||||
if anime.TitleJapanese != "" {
|
||||
searchTitles = append(searchTitles, anime.TitleJapanese)
|
||||
}
|
||||
for _, syn := range anime.TitleSynonyms {
|
||||
if syn != "" && syn != anime.Title && syn != anime.TitleEnglish && syn != anime.TitleJapanese {
|
||||
searchTitles = append(searchTitles, syn)
|
||||
}
|
||||
}
|
||||
|
||||
canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, domain.Anime{Anime: anime}, false)
|
||||
if err != nil {
|
||||
return domain.WatchPageData{}, fmt.Errorf("failed to fetch episodes: %w", err)
|
||||
}
|
||||
|
||||
requestedMode := mode
|
||||
modeSwitchedFrom := ""
|
||||
if epNum, parseErr := strconv.Atoi(episode); parseErr == nil && requestedMode == "dub" {
|
||||
for _, ep := range canonicalEpisodes.Episodes {
|
||||
if ep.Number == epNum && !ep.HasDub && ep.HasSub {
|
||||
mode = "sub"
|
||||
modeSwitchedFrom = requestedMode
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modeSources := map[string]domain.ModeSource{}
|
||||
var result *domain.StreamResult
|
||||
|
||||
for _, m := range []string{"sub", "dub"} {
|
||||
for _, p := range s.providers {
|
||||
res, err := p.GetStreams(ctx, animeID, searchTitles, episode, m)
|
||||
if err != nil || res == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var subItems []domain.SubtitleItem
|
||||
for _, sub := range res.Subtitles {
|
||||
subToken, _ := s.SignProxyToken(sub.URL, res.Referer, "subtitle")
|
||||
subItems = append(subItems, domain.SubtitleItem{
|
||||
Lang: sub.Label,
|
||||
Token: subToken,
|
||||
})
|
||||
}
|
||||
|
||||
streamToken, _ := s.SignProxyToken(res.URL, res.Referer, "stream")
|
||||
modeSources[m] = domain.ModeSource{
|
||||
Token: streamToken,
|
||||
Subtitles: subItems,
|
||||
}
|
||||
|
||||
if m == mode {
|
||||
result = res
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(modeSources) == 0 {
|
||||
return domain.WatchPageData{}, fmt.Errorf("no streams found")
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return domain.WatchPageData{}, fmt.Errorf("no streams found for mode %s", mode)
|
||||
}
|
||||
|
||||
// 3. Get start time from progress
|
||||
startTime := 0.0
|
||||
var watchlistStatus string
|
||||
var watchlistIDs []int64
|
||||
if userID != "" {
|
||||
entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: int64(animeID),
|
||||
})
|
||||
if err == nil {
|
||||
watchlistStatus = entry.Status
|
||||
watchlistIDs = []int64{entry.AnimeID}
|
||||
if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode {
|
||||
startTime = entry.CurrentTimeSeconds
|
||||
} else if anime.Episodes > 0 && episode == strconv.Itoa(anime.Episodes) && entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 == int64(anime.Episodes) {
|
||||
startTime = entry.CurrentTimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to continue_watching_entry for progress if not in watchlist
|
||||
if startTime == 0 {
|
||||
cwEntry, err := s.repo.GetContinueWatchingEntry(ctx, db.GetContinueWatchingEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: int64(animeID),
|
||||
})
|
||||
if err == nil {
|
||||
if cwEntry.CurrentEpisode.Valid && strconv.FormatInt(cwEntry.CurrentEpisode.Int64, 10) == episode {
|
||||
startTime = cwEntry.CurrentTimeSeconds
|
||||
} else if anime.Episodes > 0 && episode == strconv.Itoa(anime.Episodes) && cwEntry.CurrentEpisode.Valid && cwEntry.CurrentEpisode.Int64 == int64(anime.Episodes) {
|
||||
startTime = cwEntry.CurrentTimeSeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Build provider data
|
||||
streams := []domain.ProviderStream{
|
||||
{
|
||||
Name: "Primary",
|
||||
Quality: "Auto",
|
||||
MalID: animeID,
|
||||
IsCurrent: true,
|
||||
},
|
||||
}
|
||||
|
||||
go s.warmStreamURL(result.URL, result.Referer)
|
||||
|
||||
// 6. Resolve relations/seasons
|
||||
relations, _ := s.jikan.GetFullRelations(ctx, animeID)
|
||||
var seasons []domain.SeasonEntry
|
||||
tvCounter := 1
|
||||
for _, rel := range relations {
|
||||
if strings.ToLower(rel.Anime.Type) == "tv" || strings.ToLower(rel.Anime.Type) == "movie" {
|
||||
seasons = append(seasons, domain.SeasonEntry{
|
||||
MalID: rel.Anime.MalID,
|
||||
Title: rel.Anime.DisplayTitle(),
|
||||
Prefix: rel.Relation,
|
||||
IsCurrent: rel.IsCurrent,
|
||||
})
|
||||
if rel.Relation == "TV" {
|
||||
seasons[len(seasons)-1].Prefix = fmt.Sprintf("S%d", tvCounter)
|
||||
tvCounter++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final assembly
|
||||
segments := s.fetchSkipSegments(ctx, userID, animeID, episode)
|
||||
|
||||
watchData := domain.WatchData{
|
||||
MalID: animeID,
|
||||
Title: anime.DisplayTitle(),
|
||||
CurrentEpisode: episode,
|
||||
StartTimeSeconds: startTime,
|
||||
Episodes: canonicalEpisodes.Episodes,
|
||||
Providers: []domain.ProviderData{
|
||||
{Streams: streams},
|
||||
},
|
||||
ModeSources: modeSources,
|
||||
InitialMode: mode,
|
||||
ModeSwitchedFrom: modeSwitchedFrom,
|
||||
AvailableModes: func() []string {
|
||||
var modes []string
|
||||
for m := range modeSources {
|
||||
modes = append(modes, m)
|
||||
}
|
||||
sort.Strings(modes)
|
||||
return modes
|
||||
}(),
|
||||
Segments: segments,
|
||||
Airing: anime.Airing,
|
||||
}
|
||||
|
||||
return domain.WatchPageData{
|
||||
WatchData: watchData,
|
||||
Anime: domain.Anime{Anime: anime},
|
||||
Episodes: canonicalEpisodes.Episodes,
|
||||
CurrentEpID: episode,
|
||||
WatchlistStatus: watchlistStatus,
|
||||
WatchlistIDs: watchlistIDs,
|
||||
Seasons: seasons,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *playbackService) CompleteAnime(ctx context.Context, userID string, animeID int64) error {
|
||||
if err := s.repo.InTx(ctx, func(txCtx context.Context, repo domain.PlaybackRepository) error {
|
||||
entry, err := repo.GetWatchListEntry(txCtx, db.GetWatchListEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
})
|
||||
if err != nil || entry.Status != "completed" {
|
||||
_, err = repo.UpsertWatchListEntry(txCtx, db.UpsertWatchListEntryParams{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
Status: "completed",
|
||||
CurrentEpisode: entry.CurrentEpisode,
|
||||
CurrentTimeSeconds: entry.CurrentTimeSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: userID,
|
||||
Action: "watch_completed",
|
||||
ResourceType: "anime",
|
||||
ResourceID: strconv.FormatInt(animeID, 10),
|
||||
}); err != nil {
|
||||
observability.Warn(
|
||||
"audit_record_failed",
|
||||
"playback",
|
||||
"",
|
||||
map[string]any{"user_id": userID, "anime_id": animeID, "action": "watch_completed"},
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playbackService) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error {
|
||||
_, err := s.repo.UpsertContinueWatchingEntry(ctx, db.UpsertContinueWatchingEntryParams{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
CurrentEpisode: sql.NullInt64{Int64: int64(episode), Valid: true},
|
||||
CurrentTimeSeconds: timeSeconds,
|
||||
DurationSeconds: sql.NullFloat64{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
metadataBytes, marshalErr := json.Marshal(struct {
|
||||
Episode int `json:"episode"`
|
||||
TimeSeconds float64 `json:"time_seconds"`
|
||||
}{Episode: episode, TimeSeconds: timeSeconds})
|
||||
if marshalErr == nil {
|
||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: userID,
|
||||
Action: "watch_progress_saved",
|
||||
ResourceType: "anime",
|
||||
ResourceID: strconv.FormatInt(animeID, 10),
|
||||
MetadataJSON: metadataBytes,
|
||||
})
|
||||
} else {
|
||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: userID,
|
||||
Action: "watch_progress_saved",
|
||||
ResourceType: "anime",
|
||||
ResourceID: strconv.FormatInt(animeID, 10),
|
||||
})
|
||||
}
|
||||
observability.Info("watch_progress_saved", "playback", "", map[string]any{
|
||||
"anime_id": animeID,
|
||||
"episode": episode,
|
||||
"time_seconds": timeSeconds,
|
||||
"user_id": userID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playbackService) UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error {
|
||||
if userID == "" {
|
||||
return fmt.Errorf("not authenticated")
|
||||
}
|
||||
if animeID <= 0 || episode <= 0 {
|
||||
return fmt.Errorf("invalid anime/episode")
|
||||
}
|
||||
t := strings.ToLower(strings.TrimSpace(skipType))
|
||||
switch t {
|
||||
case "op", "opening", "intro":
|
||||
t = "op"
|
||||
case "ed", "ending", "outro":
|
||||
t = "ed"
|
||||
default:
|
||||
return fmt.Errorf("invalid skip_type")
|
||||
}
|
||||
if !(startTime >= 0) || !(endTime > startTime) {
|
||||
return fmt.Errorf("invalid interval")
|
||||
}
|
||||
// let the player-side filters ignore obviously wrong durations, but keep some sanity.
|
||||
if endTime-startTime < 5 || endTime-startTime > 10*60 {
|
||||
return fmt.Errorf("interval duration out of range")
|
||||
}
|
||||
return s.repo.UpsertSkipSegmentOverride(ctx, db.SkipSegmentOverrideRow{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
Episode: int64(episode),
|
||||
SkipType: t,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string, malID int, episode string) []domain.SkipSegment {
|
||||
if malID <= 0 || strings.TrimSpace(episode) == "" {
|
||||
return []domain.SkipSegment{}
|
||||
}
|
||||
|
||||
segments := []domain.SkipSegment{}
|
||||
|
||||
endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err == nil {
|
||||
req.Header.Set("User-Agent", netutil.Generic)
|
||||
if resp, err := s.httpClient.Do(req); err == nil {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
if body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.KiB512)); err == nil {
|
||||
type resultItem struct {
|
||||
SkipType string `json:"skip_type"`
|
||||
Interval struct {
|
||||
StartTime float64 `json:"start_time"`
|
||||
EndTime float64 `json:"end_time"`
|
||||
} `json:"interval"`
|
||||
}
|
||||
type apiResponse struct {
|
||||
Found bool `json:"found"`
|
||||
Result []resultItem `json:"results"`
|
||||
}
|
||||
|
||||
var parsed apiResponse
|
||||
if err := json.Unmarshal(body, &parsed); err == nil && parsed.Found && len(parsed.Result) > 0 {
|
||||
segments = make([]domain.SkipSegment, 0, len(parsed.Result))
|
||||
for _, r := range parsed.Result {
|
||||
skipType := strings.ToLower(r.SkipType)
|
||||
switch skipType {
|
||||
case "op":
|
||||
skipType = "opening"
|
||||
case "ed":
|
||||
skipType = "ending"
|
||||
}
|
||||
segments = append(segments, domain.SkipSegment{
|
||||
Type: skipType,
|
||||
Start: r.Interval.StartTime,
|
||||
End: r.Interval.EndTime,
|
||||
Source: "aniskip",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
epNum, _ := strconv.ParseInt(strings.TrimSpace(episode), 10, 64)
|
||||
if userID != "" && epNum > 0 {
|
||||
if ok, err := s.repo.HasSkipSegmentOverrideTable(ctx); err == nil && ok {
|
||||
if overrides, err := s.repo.ListSkipSegmentOverrides(ctx, userID, int64(malID), epNum); err == nil {
|
||||
// Build map keyed by normalized type ("opening"/"ending")
|
||||
overrideByType := make(map[string]domain.SkipSegment, len(overrides))
|
||||
for _, o := range overrides {
|
||||
t := strings.ToLower(strings.TrimSpace(o.SkipType))
|
||||
switch t {
|
||||
case "op", "opening", "intro":
|
||||
t = "opening"
|
||||
case "ed", "ending", "outro":
|
||||
t = "ending"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
overrideByType[t] = domain.SkipSegment{
|
||||
Type: t,
|
||||
Start: o.StartTime,
|
||||
End: o.EndTime,
|
||||
Source: "override",
|
||||
}
|
||||
}
|
||||
if len(overrideByType) > 0 {
|
||||
merged := make([]domain.SkipSegment, 0, len(segments)+len(overrideByType))
|
||||
seen := map[string]bool{}
|
||||
for _, seg := range segments {
|
||||
if o, ok := overrideByType[seg.Type]; ok {
|
||||
merged = append(merged, o)
|
||||
seen[seg.Type] = true
|
||||
} else {
|
||||
merged = append(merged, seg)
|
||||
seen[seg.Type] = true
|
||||
}
|
||||
}
|
||||
for t, o := range overrideByType {
|
||||
if !seen[t] {
|
||||
merged = append(merged, o)
|
||||
}
|
||||
}
|
||||
segments = merged
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
func (s *playbackService) warmStreamURL(targetURL, referer string) {
|
||||
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -542,10 +71,6 @@ func (s *playbackService) warmStreamURL(targetURL, referer string) {
|
||||
}
|
||||
req.Header.Set("User-Agent", netutil.Firefox121)
|
||||
|
||||
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
|
||||
|
||||
210
internal/playback/skip_segments.go
Normal file
210
internal/playback/skip_segments.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
netutil "mal/pkg/net"
|
||||
)
|
||||
|
||||
func normalizeSkipType(skipType string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(skipType)) {
|
||||
case "op", "opening", "intro":
|
||||
return "op", nil
|
||||
case "ed", "ending", "outro":
|
||||
return "ed", nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid skip_type")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *playbackService) UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error {
|
||||
if userID == "" {
|
||||
return fmt.Errorf("not authenticated")
|
||||
}
|
||||
if animeID <= 0 || episode <= 0 {
|
||||
return fmt.Errorf("invalid anime/episode")
|
||||
}
|
||||
t, err := normalizeSkipType(skipType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !(startTime >= 0) || !(endTime > startTime) {
|
||||
return fmt.Errorf("invalid interval")
|
||||
}
|
||||
if endTime-startTime < 5 || endTime-startTime > 10*60 {
|
||||
return fmt.Errorf("interval duration out of range")
|
||||
}
|
||||
return s.repo.UpsertSkipSegmentOverride(ctx, db.SkipSegmentOverrideRow{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
Episode: int64(episode),
|
||||
SkipType: t,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string, malID int, episode string) []domain.SkipSegment {
|
||||
if malID <= 0 || strings.TrimSpace(episode) == "" {
|
||||
return []domain.SkipSegment{}
|
||||
}
|
||||
|
||||
segments := s.fetchAniSkipSegments(ctx, malID, episode)
|
||||
return s.applySkipSegmentOverrides(ctx, segments, userID, malID, episode)
|
||||
}
|
||||
|
||||
func (s *playbackService) fetchAniSkipSegments(ctx context.Context, malID int, episode string) []domain.SkipSegment {
|
||||
endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("User-Agent", netutil.Generic)
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.KiB512))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return parseAniSkipSegments(body)
|
||||
}
|
||||
|
||||
func parseAniSkipSegments(body []byte) []domain.SkipSegment {
|
||||
type resultItem struct {
|
||||
SkipType string `json:"skip_type"`
|
||||
Interval struct {
|
||||
StartTime float64 `json:"start_time"`
|
||||
EndTime float64 `json:"end_time"`
|
||||
} `json:"interval"`
|
||||
}
|
||||
type apiResponse struct {
|
||||
Found bool `json:"found"`
|
||||
Result []resultItem `json:"results"`
|
||||
}
|
||||
|
||||
var parsed apiResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil || !parsed.Found || len(parsed.Result) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
segments := make([]domain.SkipSegment, 0, len(parsed.Result))
|
||||
for _, item := range parsed.Result {
|
||||
segments = append(segments, domain.SkipSegment{
|
||||
Type: normalizeSkipSegmentLabel(item.SkipType),
|
||||
Start: item.Interval.StartTime,
|
||||
End: item.Interval.EndTime,
|
||||
Source: "aniskip",
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
func normalizeSkipSegmentLabel(skipType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(skipType)) {
|
||||
case "op":
|
||||
return "opening"
|
||||
case "ed":
|
||||
return "ending"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(skipType))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *playbackService) applySkipSegmentOverrides(ctx context.Context, segments []domain.SkipSegment, userID string, malID int, episode string) []domain.SkipSegment {
|
||||
epNum, err := strconv.ParseInt(strings.TrimSpace(episode), 10, 64)
|
||||
if userID == "" || err != nil || epNum <= 0 {
|
||||
return segments
|
||||
}
|
||||
|
||||
ok, err := s.repo.HasSkipSegmentOverrideTable(ctx)
|
||||
if err != nil || !ok {
|
||||
return segments
|
||||
}
|
||||
|
||||
overrides, err := s.repo.ListSkipSegmentOverrides(ctx, userID, int64(malID), epNum)
|
||||
if err != nil {
|
||||
return segments
|
||||
}
|
||||
|
||||
overrideByType := buildOverrideSegments(overrides)
|
||||
if len(overrideByType) == 0 {
|
||||
return segments
|
||||
}
|
||||
|
||||
return mergeSkipSegments(segments, overrideByType)
|
||||
}
|
||||
|
||||
func buildOverrideSegments(overrides []db.SkipSegmentOverrideRow) map[string]domain.SkipSegment {
|
||||
byType := make(map[string]domain.SkipSegment, len(overrides))
|
||||
for _, override := range overrides {
|
||||
skipType, ok := normalizeOverrideSkipType(override.SkipType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
byType[skipType] = domain.SkipSegment{
|
||||
Type: skipType,
|
||||
Start: override.StartTime,
|
||||
End: override.EndTime,
|
||||
Source: "override",
|
||||
}
|
||||
}
|
||||
|
||||
return byType
|
||||
}
|
||||
|
||||
func normalizeOverrideSkipType(skipType string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(skipType)) {
|
||||
case "op", "opening", "intro":
|
||||
return "opening", true
|
||||
case "ed", "ending", "outro":
|
||||
return "ending", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func mergeSkipSegments(segments []domain.SkipSegment, overrides map[string]domain.SkipSegment) []domain.SkipSegment {
|
||||
merged := make([]domain.SkipSegment, 0, len(segments)+len(overrides))
|
||||
seen := make(map[string]bool, len(segments))
|
||||
|
||||
for _, segment := range segments {
|
||||
if override, ok := overrides[segment.Type]; ok {
|
||||
merged = append(merged, override)
|
||||
} else {
|
||||
merged = append(merged, segment)
|
||||
}
|
||||
seen[segment.Type] = true
|
||||
}
|
||||
|
||||
for skipType, override := range overrides {
|
||||
if !seen[skipType] {
|
||||
merged = append(merged, override)
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
228
internal/playback/watch_data.go
Normal file
228
internal/playback/watch_data.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
)
|
||||
|
||||
func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (domain.WatchPageData, error) {
|
||||
anime, err := s.jikan.GetAnimeByID(ctx, animeID)
|
||||
if err != nil {
|
||||
return domain.WatchPageData{}, fmt.Errorf("failed to fetch anime: %w", err)
|
||||
}
|
||||
|
||||
animeData := domain.Anime{Anime: anime}
|
||||
s.ensureAnimeRow(ctx, animeData)
|
||||
searchTitles := buildSearchTitles(animeData, titleCandidates)
|
||||
canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, animeData, false)
|
||||
if err != nil {
|
||||
return domain.WatchPageData{}, fmt.Errorf("failed to fetch episodes: %w", err)
|
||||
}
|
||||
|
||||
mode, modeSwitchedFrom := resolveMode(episode, mode, canonicalEpisodes.Episodes)
|
||||
modeSources, result, resolvedMode, resolvedModeSwitchedFrom := s.resolveModeSources(ctx, animeID, searchTitles, episode, mode)
|
||||
if resolvedMode != "" {
|
||||
mode = resolvedMode
|
||||
}
|
||||
if resolvedModeSwitchedFrom != "" {
|
||||
modeSwitchedFrom = resolvedModeSwitchedFrom
|
||||
}
|
||||
if len(modeSources) == 0 {
|
||||
return domain.WatchPageData{}, fmt.Errorf("no streams found")
|
||||
}
|
||||
if result == nil {
|
||||
return domain.WatchPageData{}, fmt.Errorf("no streams found for mode %s", mode)
|
||||
}
|
||||
|
||||
startTime, watchlistStatus, watchlistIDs := s.loadWatchProgress(ctx, userID, animeID, anime.Episodes, episode)
|
||||
go s.warmStreamURL(result.URL, result.Referer)
|
||||
seasons := s.loadSeasons(ctx, animeID)
|
||||
segments := s.fetchSkipSegments(ctx, userID, animeID, episode)
|
||||
watchData := buildWatchDataPayload(animeData, animeID, episode, startTime, canonicalEpisodes.Episodes, modeSources, mode, modeSwitchedFrom, segments)
|
||||
return buildWatchPageData(animeData, canonicalEpisodes.Episodes, episode, watchlistStatus, watchlistIDs, seasons, watchData), nil
|
||||
}
|
||||
|
||||
func buildWatchDataPayload(anime domain.Anime, animeID int, episode string, startTime float64, episodes []domain.CanonicalEpisode, modeSources map[string]domain.ModeSource, mode string, modeSwitchedFrom string, segments []domain.SkipSegment) domain.WatchData {
|
||||
return domain.WatchData{
|
||||
MalID: animeID,
|
||||
Title: anime.DisplayTitle(),
|
||||
CurrentEpisode: episode,
|
||||
StartTimeSeconds: startTime,
|
||||
Episodes: episodes,
|
||||
Providers: []domain.ProviderData{{Streams: []domain.ProviderStream{{
|
||||
Name: "Primary",
|
||||
Quality: "Auto",
|
||||
MalID: animeID,
|
||||
IsCurrent: true,
|
||||
}}}},
|
||||
ModeSources: modeSources,
|
||||
InitialMode: mode,
|
||||
ModeSwitchedFrom: modeSwitchedFrom,
|
||||
AvailableModes: availableModes(modeSources),
|
||||
Segments: segments,
|
||||
Airing: anime.Airing,
|
||||
}
|
||||
}
|
||||
|
||||
func buildWatchPageData(anime domain.Anime, episodes []domain.CanonicalEpisode, episode string, watchlistStatus string, watchlistIDs []int64, seasons []domain.SeasonEntry, watchData domain.WatchData) domain.WatchPageData {
|
||||
return domain.WatchPageData{
|
||||
WatchData: watchData,
|
||||
Anime: anime,
|
||||
Episodes: episodes,
|
||||
CurrentEpID: episode,
|
||||
WatchlistStatus: watchlistStatus,
|
||||
WatchlistIDs: watchlistIDs,
|
||||
Seasons: seasons,
|
||||
}
|
||||
}
|
||||
|
||||
func buildSearchTitles(anime domain.Anime, titleCandidates []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, 3+len(anime.TitleSynonyms)+len(titleCandidates))
|
||||
|
||||
appendTitle := func(title string) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[title]; ok {
|
||||
return
|
||||
}
|
||||
seen[title] = struct{}{}
|
||||
out = append(out, title)
|
||||
}
|
||||
|
||||
appendTitle(anime.Title)
|
||||
appendTitle(anime.TitleEnglish)
|
||||
appendTitle(anime.TitleJapanese)
|
||||
for _, syn := range anime.TitleSynonyms {
|
||||
appendTitle(syn)
|
||||
}
|
||||
for _, candidate := range titleCandidates {
|
||||
appendTitle(candidate)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveMode(episode string, requestedMode string, episodes []domain.CanonicalEpisode) (string, string) {
|
||||
if requestedMode != "dub" {
|
||||
return requestedMode, ""
|
||||
}
|
||||
|
||||
epNum, err := strconv.Atoi(episode)
|
||||
if err != nil {
|
||||
return requestedMode, ""
|
||||
}
|
||||
|
||||
for _, ep := range episodes {
|
||||
if ep.Number == epNum && !ep.HasDub && ep.HasSub {
|
||||
return "sub", requestedMode
|
||||
}
|
||||
}
|
||||
|
||||
return requestedMode, ""
|
||||
}
|
||||
|
||||
func (s *playbackService) resolveModeSources(ctx context.Context, animeID int, searchTitles []string, episode string, requestedMode string) (map[string]domain.ModeSource, *domain.StreamResult, string, string) {
|
||||
if res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, requestedMode); res != nil {
|
||||
return map[string]domain.ModeSource{
|
||||
requestedMode: s.buildModeSource(res),
|
||||
}, res, requestedMode, ""
|
||||
}
|
||||
|
||||
for _, fallbackMode := range fallbackModes(requestedMode) {
|
||||
res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, fallbackMode)
|
||||
if res == nil {
|
||||
continue
|
||||
}
|
||||
return map[string]domain.ModeSource{
|
||||
fallbackMode: s.buildModeSource(res),
|
||||
}, res, fallbackMode, requestedMode
|
||||
}
|
||||
|
||||
return map[string]domain.ModeSource{}, nil, requestedMode, ""
|
||||
}
|
||||
|
||||
func (s *playbackService) resolveStreamResult(ctx context.Context, animeID int, searchTitles []string, episode string, mode string) *domain.StreamResult {
|
||||
for _, p := range s.providers {
|
||||
res, err := p.GetStreams(ctx, animeID, searchTitles, episode, mode)
|
||||
if err == nil && res != nil {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeSource {
|
||||
subtitles := make([]domain.SubtitleItem, 0, len(res.Subtitles))
|
||||
for _, sub := range res.Subtitles {
|
||||
token, _ := s.SignProxyToken(sub.URL, res.Referer, "subtitle")
|
||||
subtitles = append(subtitles, domain.SubtitleItem{
|
||||
Lang: sub.Label,
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
streamToken, _ := s.SignProxyToken(res.URL, res.Referer, "stream")
|
||||
return domain.ModeSource{
|
||||
Token: streamToken,
|
||||
Type: res.Type,
|
||||
Subtitles: subtitles,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *playbackService) loadSeasons(ctx context.Context, animeID int) []domain.SeasonEntry {
|
||||
relations, _ := s.jikan.GetFullRelations(ctx, animeID, jikan.WatchOrderModeMain)
|
||||
seasons := make([]domain.SeasonEntry, 0, len(relations))
|
||||
tvCounter := 1
|
||||
|
||||
for _, rel := range relations {
|
||||
animeType := strings.ToLower(rel.Anime.Type)
|
||||
if animeType != "tv" && animeType != "movie" {
|
||||
continue
|
||||
}
|
||||
|
||||
season := domain.SeasonEntry{
|
||||
MalID: rel.Anime.MalID,
|
||||
Title: rel.Anime.DisplayTitle(),
|
||||
Prefix: rel.Relation,
|
||||
IsCurrent: rel.IsCurrent,
|
||||
}
|
||||
if rel.Relation == "TV" {
|
||||
season.Prefix = fmt.Sprintf("S%d", tvCounter)
|
||||
tvCounter++
|
||||
}
|
||||
|
||||
seasons = append(seasons, season)
|
||||
}
|
||||
|
||||
return seasons
|
||||
}
|
||||
|
||||
func availableModes(modeSources map[string]domain.ModeSource) []string {
|
||||
modes := make([]string, 0, len(modeSources))
|
||||
for mode := range modeSources {
|
||||
modes = append(modes, mode)
|
||||
}
|
||||
sort.Strings(modes)
|
||||
return modes
|
||||
}
|
||||
|
||||
func fallbackModes(requestedMode string) []string {
|
||||
switch requestedMode {
|
||||
case "sub":
|
||||
return []string{"dub"}
|
||||
case "dub":
|
||||
return []string{"sub"}
|
||||
default:
|
||||
return []string{"sub", "dub"}
|
||||
}
|
||||
}
|
||||
35
internal/playback/watch_data_test.go
Normal file
35
internal/playback/watch_data_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFallbackModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
want []string
|
||||
}{
|
||||
{name: "sub falls back to dub", mode: "sub", want: []string{"dub"}},
|
||||
{name: "dub falls back to sub", mode: "dub", want: []string{"sub"}},
|
||||
{name: "unknown tries both canonical modes", mode: "raw", want: []string{"sub", "dub"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := fallbackModes(tt.mode)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len(got) = %d, want %d", len(got), len(tt.want))
|
||||
}
|
||||
for i, want := range tt.want {
|
||||
if got[i] != want {
|
||||
t.Fatalf("got[%d] = %q, want %q", i, got[i], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func CORSMiddleware() gin.HandlerFunc {
|
||||
return CORSMiddlewareWithConfig(config.Config{})
|
||||
}
|
||||
|
||||
func CORSMiddlewareWithConfig(cfg config.Config) gin.HandlerFunc {
|
||||
allowAll := cfg.CORSAllowAll
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -31,23 +31,40 @@ func RequestLogger(metrics *observability.Metrics) gin.HandlerFunc {
|
||||
level = observability.LogLevelWarn
|
||||
}
|
||||
|
||||
observability.LogJSON(
|
||||
fields := map[string]any{
|
||||
"client_ip": c.ClientIP(),
|
||||
"duration_ms": float64(duration.Microseconds()) / 1000,
|
||||
"method": c.Request.Method,
|
||||
"path": path,
|
||||
"request_id": c.Writer.Header().Get(requestIDHeader),
|
||||
"status": status,
|
||||
}
|
||||
privateErrors := c.Errors.ByType(gin.ErrorTypePrivate)
|
||||
var logErr error
|
||||
if len(privateErrors) > 0 {
|
||||
logErr = privateErrors.Last().Err
|
||||
}
|
||||
if route != path {
|
||||
fields["route"] = route
|
||||
}
|
||||
if query != "" {
|
||||
fields["query"] = query
|
||||
}
|
||||
if size := c.Writer.Size(); size >= 0 {
|
||||
fields["bytes"] = size
|
||||
}
|
||||
if errors := privateErrors.String(); errors != "" {
|
||||
fields["errors"] = errors
|
||||
}
|
||||
|
||||
observability.LogContext(
|
||||
c.Request.Context(),
|
||||
level,
|
||||
"http_request",
|
||||
"http",
|
||||
"",
|
||||
map[string]any{
|
||||
"method": c.Request.Method,
|
||||
"route": route,
|
||||
"path": path,
|
||||
"query": query,
|
||||
"status": status,
|
||||
"duration_ms": float64(duration.Microseconds()) / 1000,
|
||||
"bytes": c.Writer.Size(),
|
||||
"client_ip": c.ClientIP(),
|
||||
"errors": c.Errors.ByType(gin.ErrorTypePrivate).String(),
|
||||
},
|
||||
nil,
|
||||
c.Request.Method+" "+path,
|
||||
fields,
|
||||
logErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
30
internal/server/request_context.go
Normal file
30
internal/server/request_context.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"mal/internal/observability"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const requestIDHeader = "X-Request-ID"
|
||||
|
||||
func RequestContextMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := strings.TrimSpace(c.GetHeader(requestIDHeader))
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
}
|
||||
|
||||
path := c.Request.URL.Path
|
||||
route := c.FullPath()
|
||||
if route == "" {
|
||||
route = path
|
||||
}
|
||||
|
||||
c.Writer.Header().Set(requestIDHeader, requestID)
|
||||
c.Request = c.Request.WithContext(observability.WithRequestContext(c.Request.Context(), requestID, path, route))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,18 @@ func RespondError(c *gin.Context, status int, event string, component string, me
|
||||
if status >= http.StatusInternalServerError {
|
||||
level = observability.LogLevelError
|
||||
}
|
||||
observability.LogJSON(level, event, component, "", fields, err)
|
||||
if fields == nil {
|
||||
fields = make(map[string]any, 2)
|
||||
}
|
||||
if _, exists := fields["request_path"]; !exists {
|
||||
fields["request_path"] = c.Request.URL.Path
|
||||
}
|
||||
if route := c.FullPath(); route != "" && route != c.Request.URL.Path {
|
||||
if _, exists := fields["request_route"]; !exists {
|
||||
fields["request_route"] = route
|
||||
}
|
||||
}
|
||||
observability.LogContext(c.Request.Context(), level, event, component, "", fields, err)
|
||||
RespondHTMLOrJSONError(c, status, message)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ func ProvideRouter(cfg config.Config, htmlRender render.HTMLRender, metrics *obs
|
||||
gin.SetMode(cfg.GinMode)
|
||||
}
|
||||
r := gin.New()
|
||||
r.Use(CORSMiddlewareWithConfig(cfg), audit.ContextMiddleware(), RequestLogger(metrics), gin.Recovery())
|
||||
r.Use(CORSMiddlewareWithConfig(cfg), RequestContextMiddleware(), audit.ContextMiddleware(), RequestLogger(metrics), gin.Recovery())
|
||||
r.Static("/static", "./static")
|
||||
r.Static("/dist", "./dist")
|
||||
r.GET("/metrics", gin.WrapH(metrics.Handler()))
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"mal/internal/observability"
|
||||
@@ -43,12 +44,13 @@ func TestRequestLoggerUsesMatchedRoute(t *testing.T) {
|
||||
defer log.SetOutput(previousOutput)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(RequestContextMiddleware())
|
||||
router.Use(RequestLogger(observability.NewMetrics()))
|
||||
router.GET("/anime/:id", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/anime/1?section=characters", nil)
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/anime/1?section=characters", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
@@ -58,13 +60,54 @@ func TestRequestLoggerUsesMatchedRoute(t *testing.T) {
|
||||
}
|
||||
|
||||
logLine := string(output)
|
||||
if !strings.Contains(logLine, `"event":"http_request"`) {
|
||||
t.Fatalf("log line missing event: %s", logLine)
|
||||
if !strings.Contains(logLine, " INFO http 200 GET /anime/1") {
|
||||
t.Fatalf("log line missing compact http summary: %s", logLine)
|
||||
}
|
||||
if !strings.Contains(logLine, `"route":"/anime/:id"`) {
|
||||
if !strings.Contains(logLine, " route=/anime/:id") {
|
||||
t.Fatalf("log line missing route: %s", logLine)
|
||||
}
|
||||
if !strings.Contains(logLine, `"status":200`) {
|
||||
t.Fatalf("log line missing status: %s", logLine)
|
||||
if !strings.Contains(logLine, " request_id=") {
|
||||
t.Fatalf("log line missing request id: %s", logLine)
|
||||
}
|
||||
if strings.Contains(logLine, `"GET /anime/1"`) {
|
||||
t.Fatalf("log line should not duplicate request summary: %s", logLine)
|
||||
}
|
||||
if rec.Header().Get(requestIDHeader) == "" {
|
||||
t.Fatalf("expected %s response header to be set", requestIDHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondErrorIncludesRequestContext(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var logs bytes.Buffer
|
||||
previousOutput := log.Writer()
|
||||
log.SetOutput(&logs)
|
||||
defer log.SetOutput(previousOutput)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(RequestContextMiddleware())
|
||||
router.GET("/anime/:id", func(c *gin.Context) {
|
||||
RespondError(c, http.StatusInternalServerError, "anime_lookup_failed", "anime", "failed", nil, context.DeadlineExceeded)
|
||||
})
|
||||
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/anime/1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
output, err := io.ReadAll(&logs)
|
||||
if err != nil {
|
||||
t.Fatalf("read logs: %v", err)
|
||||
}
|
||||
|
||||
logLine := string(output)
|
||||
if !strings.Contains(logLine, " request_id=") {
|
||||
t.Fatalf("log line missing request id: %s", logLine)
|
||||
}
|
||||
if !strings.Contains(logLine, " request_path=/anime/1") {
|
||||
t.Fatalf("log line missing request path: %s", logLine)
|
||||
}
|
||||
if !strings.Contains(logLine, " request_route=/anime/:id") {
|
||||
t.Fatalf("log line missing request route: %s", logLine)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user