Files
silo-server/internal/api/handlers/watch_tonight_cards.go
T

489 lines
14 KiB
Go

package handlers
import (
"context"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/recommendations"
)
// cardsCastFetcher fetches cast credits for a batch of media items.
type cardsCastFetcher interface {
ListForItems(ctx context.Context, contentIDs []string) (map[string][]models.ItemPerson, error)
}
// swipeCardCastMember is a single cast member on a swipe card.
type swipeCardCastMember struct {
Name string `json:"name"`
Character string `json:"character,omitempty"`
PhotoURL string `json:"photo_url,omitempty"`
}
// swipeCardResponse extends sectionItemResponse with cast and runtime for the card back.
type swipeCardResponse struct {
sectionItemResponse
WatchTonightSource string `json:"watch_tonight_source"`
Runtime int `json:"runtime,omitempty"`
Cast []swipeCardCastMember `json:"cast"`
}
// swipeCardsPageResponse is the response for GET /recommendations/watch-tonight/cards.
type swipeCardsPageResponse struct {
Cards []swipeCardResponse `json:"cards"`
HasMore bool `json:"has_more"`
IsCold bool `json:"is_cold"`
}
const (
swipeDefaultLimit = 12
swipeMaxLimit = 20
maxExcludeIDs = 200
maxCastPerCard = 4
discoverMinPoolSize = 120
discoverMaxPoolSize = 500
)
// knownGenres is the validated set of genre names accepted by the cards endpoint.
var knownGenres = map[string]struct{}{
"Action": {}, "Adventure": {}, "Animation": {}, "Comedy": {},
"Crime": {}, "Documentary": {}, "Drama": {}, "Family": {},
"Fantasy": {}, "History": {}, "Horror": {}, "Music": {},
"Mystery": {}, "Romance": {}, "Science Fiction": {}, "Thriller": {},
"War": {}, "Western": {},
}
// HandleWatchTonightCards handles GET /recommendations/watch-tonight/cards.
// It returns paginated, genre-filterable swipe cards with cast data for the
// gamified Watch Tonight experience.
func (h *RecommendationsHandler) HandleWatchTonightCards(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
filter := requestAccessFilter(r)
mode := r.URL.Query().Get("mode")
if mode != "continue" && mode != "discover" {
writeError(w, http.StatusBadRequest, "invalid_mode", `mode must be "continue" or "discover"`)
return
}
genres := parseGenreParams(r.URL.Query()["genres[]"])
excludeIDs := parseExcludeIDs(r.URL.Query()["exclude_ids[]"])
limit := swipeDefaultLimit
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= swipeMaxLimit {
limit = n
}
}
var merged []mergedItem
var isCold bool
if mode == "continue" {
merged, isCold = h.buildContinueCards(r, userID, profileID, filter, excludeIDs, limit)
} else {
merged, isCold = h.buildDiscoverCards(r, userID, profileID, genres, excludeIDs, limit)
}
resp := swipeCardsPageResponse{
Cards: make([]swipeCardResponse, 0, limit),
IsCold: isCold,
}
if len(merged) == 0 {
writeJSON(w, http.StatusOK, resp)
return
}
// Over-fetch for enrichment so we can still fill a full page after item
// lookups drop inaccessible or missing entries. Trim to limit+buffer to
// control enrichment cost.
enrichLimit := limit + 10
if len(merged) > enrichLimit {
merged = merged[:enrichLimit]
}
// Collect content IDs for enrichment.
contentIDs := make([]string, len(merged))
for i, m := range merged {
contentIDs[i] = m.scored.MediaItemID
}
itemMap, overlayMap, stateMap, enrichedEpMeta := h.enrichItems(r, userID, profileID, filter, contentIDs)
// Determine if there are more items beyond what we return.
resp.HasMore = len(merged) > limit
if len(merged) > limit {
merged = merged[:limit]
}
// First pass: build enriched items and collect cast lookup IDs.
type enrichedCard struct {
item sectionItemResponse
source string
runtime int
}
enrichedCards := make([]enrichedCard, 0, len(merged))
castLookupIDs := make([]string, 0, len(merged))
castLookupSeen := make(map[string]struct{}, len(merged))
// Map content ID → cast lookup ID for the second pass.
contentToCastLookup := make(map[string]string, len(merged))
for _, m := range merged {
mi, ok := itemMap[m.scored.MediaItemID]
if !ok || mi == nil {
continue
}
item := h.buildSectionItem(r, mi, overlayMap, stateMap)
item.ItemSource = m.source
// Apply CW/Next-Up metadata.
if m.meta != nil {
item.PositionSeconds = m.meta.PositionSeconds
item.DurationSeconds = m.meta.DurationSeconds
item.ProgressUpdatedAt = m.meta.ProgressUpdatedAt
if m.meta.SeriesID != nil {
item.SeriesID = *m.meta.SeriesID
}
if m.meta.SeriesTitle != "" {
item.SeriesTitle = m.meta.SeriesTitle
}
if m.meta.SeasonNumber != nil {
item.SeasonNumber = m.meta.SeasonNumber
}
if m.meta.EpisodeNumber != nil {
item.EpisodeNumber = m.meta.EpisodeNumber
}
}
if epMeta, ok := enrichedEpMeta[m.scored.MediaItemID]; ok {
if epMeta.SeriesID != nil && item.SeriesID == "" {
item.SeriesID = *epMeta.SeriesID
}
if epMeta.SeriesTitle != "" && item.SeriesTitle == "" {
item.SeriesTitle = epMeta.SeriesTitle
}
if epMeta.SeasonNumber != nil && item.SeasonNumber == nil {
item.SeasonNumber = epMeta.SeasonNumber
}
if epMeta.EpisodeNumber != nil && item.EpisodeNumber == nil {
item.EpisodeNumber = epMeta.EpisodeNumber
}
}
// Resolve cast lookup ID: use series ID for episodes.
castLookupID := m.scored.MediaItemID
if item.SeriesID != "" {
castLookupID = item.SeriesID
}
contentToCastLookup[m.scored.MediaItemID] = castLookupID
if _, seen := castLookupSeen[castLookupID]; !seen {
castLookupSeen[castLookupID] = struct{}{}
castLookupIDs = append(castLookupIDs, castLookupID)
}
enrichedCards = append(enrichedCards, enrichedCard{
item: item,
source: m.source,
runtime: mi.Runtime,
})
}
// Fetch cast using the properly resolved lookup IDs.
castMap := h.fetchCastByIDs(r.Context(), castLookupIDs)
// Second pass: assemble response cards with cast.
for i, ec := range enrichedCards {
lookupID := contentToCastLookup[merged[i].scored.MediaItemID]
cast := buildCastSlice(castMap, lookupID)
resp.Cards = append(resp.Cards, swipeCardResponse{
sectionItemResponse: ec.item,
WatchTonightSource: ec.source,
Runtime: ec.runtime,
Cast: cast,
})
}
writeJSON(w, http.StatusOK, resp)
}
// buildContinueCards returns CW + Next Up items as swipe cards.
func (h *RecommendationsHandler) buildContinueCards(r *http.Request, userID int, profileID string, filter catalog.AccessFilter, excludeIDs map[string]struct{}, limit int) ([]mergedItem, bool) {
cwItems, nextUpItems, err := h.fetchLiveCWAndNextUp(r, userID, profileID, filter, limit+len(excludeIDs))
if err != nil {
slog.Warn("WatchTonightCards: CW/NextUp fetch failed", "user_id", userID, "error", err)
return nil, false
}
merged := make([]mergedItem, 0, len(cwItems)+len(nextUpItems))
for _, cw := range cwItems {
if _, excluded := excludeIDs[cw.contentID]; excluded {
continue
}
merged = append(merged, mergedItem{
scored: recommendations.ScoredItem{
MediaItemID: cw.contentID,
Score: baseCWScore,
Reason: "continue_watching",
},
source: "continue_watching",
meta: &cw.meta,
})
}
for _, nu := range nextUpItems {
if _, excluded := excludeIDs[nu.contentID]; excluded {
continue
}
merged = append(merged, mergedItem{
scored: recommendations.ScoredItem{
MediaItemID: nu.contentID,
Score: baseNextUpScore,
Reason: "next_up",
},
source: "next_up",
meta: &nu.meta,
})
}
sort.Slice(merged, func(i, j int) bool {
return merged[i].scored.Score > merged[j].scored.Score
})
isCold := len(merged) == 0
return merged, isCold
}
// buildDiscoverCards returns recommendation candidates for discover mode.
// It searches the user's taste profile against the full embedded library,
// optionally requiring at least one selected genre match, and falls back to
// cold-start caches when no taste profile is available.
func (h *RecommendationsHandler) buildDiscoverCards(r *http.Request, userID int, profileID string, genres []string, excludeIDs map[string]struct{}, limit int) ([]mergedItem, bool) {
if h.recsRepo == nil {
return nil, true
}
ctx := r.Context()
filter := requestAccessFilter(r)
poolSize := discoverCandidatePoolSize(limit, len(genres))
embedding, err := h.recsRepo.GetTasteProfile(ctx, userID, profileID)
if err != nil {
slog.Warn("WatchTonightCards: taste profile fetch failed", "user_id", userID, "profile_id", profileID, "error", err)
}
candidates := []recommendations.ScoredItem{}
genreMap := map[string][]string{}
isCold := embedding == nil
if embedding != nil {
candidateItems, candidateGenres, err := h.recsRepo.FindTasteProfileCandidates(
ctx,
embedding,
mapKeys(excludeIDs),
genres,
poolSize,
filter,
)
if err != nil {
slog.Warn("WatchTonightCards: taste candidate query failed", "user_id", userID, "profile_id", profileID, "error", err)
} else {
candidates = candidateItems
genreMap = candidateGenres
}
}
if len(candidates) == 0 {
isCold = true
candidates = h.buildColdStartDiscoverCandidates(ctx)
if len(candidates) > 0 {
candidateIDs := make([]string, len(candidates))
for i, item := range candidates {
candidateIDs[i] = item.MediaItemID
}
genreMap, _ = h.recsRepo.GetItemAllGenres(ctx, candidateIDs)
}
}
candidates = h.filterRecommendations(r, userID, profileID, candidates)
candidates = recommendations.FilterAndRankGenreMatches(candidates, genres, genreMap)
return recommendationCardItems(candidates, excludeIDs), isCold
}
func recommendationCardItems(candidates []recommendations.ScoredItem, excludeIDs map[string]struct{}) []mergedItem {
merged := make([]mergedItem, 0, len(candidates))
for _, item := range candidates {
if _, excluded := excludeIDs[item.MediaItemID]; excluded {
continue
}
merged = append(merged, mergedItem{
scored: item,
source: "recommendation",
})
}
return merged
}
func (h *RecommendationsHandler) buildColdStartDiscoverCandidates(ctx context.Context) []recommendations.ScoredItem {
byID := make(map[string]recommendations.ScoredItem)
popular, err := h.recsRepo.GetRecommendationCache(
ctx,
recommendations.GlobalCacheUserID,
recommendations.GlobalCacheProfileID,
recommendations.RecTypePopular,
"",
)
if err != nil {
slog.Warn("WatchTonightCards: popular cache unavailable", "error", err)
}
for _, item := range popular {
byID[item.MediaItemID] = item
}
recentlyAdded, err := h.recsRepo.GetRecommendationCache(
ctx,
recommendations.GlobalCacheUserID,
recommendations.GlobalCacheProfileID,
recommendations.RecTypeRecentlyAdded,
"",
)
if err != nil {
slog.Warn("WatchTonightCards: recently added cache unavailable", "error", err)
}
for _, item := range recentlyAdded {
if existing, exists := byID[item.MediaItemID]; !exists || item.Score > existing.Score {
byID[item.MediaItemID] = item
}
}
candidates := make([]recommendations.ScoredItem, 0, len(byID))
for _, item := range byID {
candidates = append(candidates, item)
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].Score != candidates[j].Score {
return candidates[i].Score > candidates[j].Score
}
return candidates[i].MediaItemID < candidates[j].MediaItemID
})
return candidates
}
// fetchCastByIDs fetches cast members for the given pre-resolved lookup IDs.
func (h *RecommendationsHandler) fetchCastByIDs(ctx context.Context, lookupIDs []string) map[string][]models.ItemPerson {
if h.CastFetcher == nil || len(lookupIDs) == 0 {
return nil
}
castMap, err := h.CastFetcher.ListForItems(ctx, lookupIDs)
if err != nil {
slog.Error("WatchTonightCards: cast fetch failed", "error", err)
return nil
}
return castMap
}
// buildCastSlice builds a capped list of actor cast members for a card.
func buildCastSlice(castMap map[string][]models.ItemPerson, lookupID string) []swipeCardCastMember {
if castMap == nil {
return []swipeCardCastMember{}
}
people, ok := castMap[lookupID]
if !ok || len(people) == 0 {
return []swipeCardCastMember{}
}
cast := make([]swipeCardCastMember, 0, maxCastPerCard)
for _, p := range people {
if p.Kind != models.PersonKindActor {
continue
}
cast = append(cast, swipeCardCastMember{
Name: p.Name,
Character: p.Character,
})
if len(cast) >= maxCastPerCard {
break
}
}
return cast
}
// parseGenreParams validates and deduplicates genre query params.
func parseGenreParams(raw []string) []string {
if len(raw) == 0 {
return nil
}
seen := make(map[string]struct{}, len(raw))
genres := make([]string, 0, len(raw))
for _, g := range raw {
g = strings.TrimSpace(g)
if _, valid := knownGenres[g]; !valid {
continue
}
if _, dup := seen[g]; dup {
continue
}
seen[g] = struct{}{}
genres = append(genres, g)
}
return genres
}
func discoverCandidatePoolSize(limit int, genreCount int) int {
multiplier := 15
if genreCount > 0 {
multiplier = 20
}
size := limit * multiplier
if size < discoverMinPoolSize {
size = discoverMinPoolSize
}
if size > discoverMaxPoolSize {
size = discoverMaxPoolSize
}
return size
}
func mapKeys(values map[string]struct{}) []string {
if len(values) == 0 {
return nil
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
return keys
}
// parseExcludeIDs extracts and caps the exclude_ids query params.
func parseExcludeIDs(raw []string) map[string]struct{} {
if len(raw) == 0 {
return nil
}
ids := make(map[string]struct{}, len(raw))
for _, id := range raw {
id = strings.TrimSpace(id)
if id == "" {
continue
}
ids[id] = struct{}{}
if len(ids) >= maxExcludeIDs {
break
}
}
return ids
}