package api import ( "context" "encoding/json" "net/http" "strings" "time" "github.com/ponzischeme89/memby/server/internal/store" ) type heroAdminItem struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Year int `json:"year,omitempty"` } type heroAdminPolicy struct { PinnedItems []heroAdminItem `json:"pinnedItems"` Items []heroAdminItem `json:"items"` PrimeSubtitle string `json:"primeSubtitle"` Placements map[string]heroAdminPlacement `json:"placements"` Schedules []store.HeroSchedule `json:"schedules"` TimeZone string `json:"timeZone"` } type heroAdminPlacement struct { PinnedItems []heroAdminItem `json:"pinnedItems"` PrimeSubtitle string `json:"primeSubtitle"` } func adminHeroItem(raw json.RawMessage) (heroAdminItem, bool) { fact, ok := heroFactsOf(raw) if !ok || !fact.Playable || (!strings.EqualFold(fact.Type, "Movie") && !strings.EqualFold(fact.Type, "Series")) { return heroAdminItem{}, false } return heroAdminItem{ID: fact.ID, Name: fact.Name, Type: fact.Type, Year: heroYearOf(fact)}, true } func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy { policy, err := s.store.HeroPolicy(ctx) if err != nil { s.loggerFor(ctx).Warn("hero policy unavailable to admin", "error", err) return heroAdminPolicy{PinnedItems: []heroAdminItem{}, Placements: map[string]heroAdminPlacement{}} } allIDs := []string{} for _, placement := range policy.Placements { allIDs = append(allIDs, placement.PinnedItemIDs...) } for _, schedule := range policy.Schedules { allIDs = append(allIDs, schedule.ItemID) } allIDs = uniqueHeroIDs(allIDs) items, err := s.store.LibraryItemsByID(ctx, allIDs) if err != nil { s.loggerFor(ctx).Warn("pinned hero titles unavailable to admin", "error", err) return heroAdminPolicy{PinnedItems: []heroAdminItem{}, Placements: map[string]heroAdminPlacement{}} } byID := make(map[string]heroAdminItem, len(items)) for _, raw := range items { if item, ok := adminHeroItem(raw); ok { byID[item.ID] = item } } out := heroAdminPolicy{ Placements: make(map[string]heroAdminPlacement, len(policy.Placements)), Items: []heroAdminItem{}, Schedules: policy.Schedules, TimeZone: heroScheduleTimeZone(s.cfg.RadarrLocation), } for _, id := range allIDs { if item, ok := byID[id]; ok { out.Items = append(out.Items, item) } } for name, placement := range policy.Placements { adminPlacement := heroAdminPlacement{PinnedItems: []heroAdminItem{}, PrimeSubtitle: placement.PrimeSubtitle} for _, id := range placement.PinnedItemIDs { if item, ok := byID[id]; ok { adminPlacement.PinnedItems = append(adminPlacement.PinnedItems, item) } } out.Placements[name] = adminPlacement } home := out.Placements[store.HeroPlacementHome] out.PinnedItems, out.PrimeSubtitle = home.PinnedItems, home.PrimeSubtitle return out } func heroScheduleTimeZone(location *time.Location) string { if location == nil { location = time.Local } return location.String() } // heroSearchLimit is what either half of the picker's search may contribute, and what the // merged answer is trimmed back to. const heroSearchLimit = 20 // handleAdminHeroSearch answers the picker from the imported catalogue and from Emby. // // The catalogue alone is up to a sync interval stale, so a film imported this afternoon // was simply not findable here until the next hourly pass — and pinning is the one hero // decision an operator makes about a title *because* it has just arrived. Emby is asked // as well and `Find` imports what it returns, which is what makes a fresh id usable by the // policy validation and by the hero row itself rather than only by this list. // // Either half may fail without failing the search: a stale answer and a live one are both // better than an error, and the two are deliberately asked in that order so a gateway with // no Emby credentials configured still has a picker. func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) { term := strings.TrimSpace(r.URL.Query().Get("q")) items, err := s.store.SearchLibrary(r.Context(), term, heroSearchLimit) if err != nil { s.loggerFor(r.Context()).Error("hero library search failed", "error", err) writeError(w, http.StatusInternalServerError, "could not search the library") return } if s.syncer != nil && term != "" { found, findErr := s.syncer.Find(r.Context(), term, heroSearchLimit) if findErr != nil { s.loggerFor(r.Context()).Warn("hero live search unavailable", "error", findErr) } items = append(items, found...) } writeJSON(w, http.StatusOK, map[string]any{"items": mergeHeroSearchResults(items, heroSearchLimit)}) } // mergeHeroSearchResults turns both halves of the search into one list. // // Almost every title comes back from both, so the dedupe is the ordinary case rather than // the exception, and it keeps the first sighting: the catalogue answers first, and its // ranking is the one an operator has been reading all along. Anything an id appears in // only once is either a title Emby has and the last import missed — the whole point — or // one deleted from Emby that the catalogue has not swept yet. func mergeHeroSearchResults(items []json.RawMessage, limit int) []heroAdminItem { results := make([]heroAdminItem, 0, len(items)) seen := make(map[string]bool, len(items)) for _, raw := range items { if len(results) >= limit { break } item, ok := adminHeroItem(raw) if !ok || seen[item.ID] { continue } seen[item.ID] = true results = append(results, item) } return results } func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) { var request struct { PinnedItemIDs []string `json:"pinnedItemIds"` PrimeSubtitle string `json:"primeSubtitle"` Placements map[string]struct { PinnedItemIDs []string `json:"pinnedItemIds"` PrimeSubtitle string `json:"primeSubtitle"` } `json:"placements"` Schedules []store.HeroSchedule `json:"schedules"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { writeError(w, http.StatusBadRequest, "invalid hero policy") return } if request.Placements == nil { request.Placements = map[string]struct { PinnedItemIDs []string `json:"pinnedItemIds"` PrimeSubtitle string `json:"primeSubtitle"` }{ store.HeroPlacementHome: {PinnedItemIDs: request.PinnedItemIDs, PrimeSubtitle: request.PrimeSubtitle}, } } allIDs := []string{} placementPolicies := map[string]store.HeroPlacementPolicy{} for name, placement := range request.Placements { if !store.ValidHeroPlacement(name) { writeError(w, http.StatusBadRequest, "unknown hero placement") return } ids := uniqueHeroIDs(placement.PinnedItemIDs) if len(ids) > 4 { writeError(w, http.StatusBadRequest, "each hero can pin at most four titles") return } placementPolicies[name] = store.HeroPlacementPolicy{PinnedItemIDs: ids, PrimeSubtitle: placement.PrimeSubtitle} allIDs = append(allIDs, ids...) } for _, schedule := range request.Schedules { allIDs = append(allIDs, schedule.ItemID) } items, err := s.store.LibraryItemsByID(r.Context(), uniqueHeroIDs(allIDs)) if err != nil { s.loggerFor(r.Context()).Error("hero title validation failed", "error", err) writeError(w, http.StatusInternalServerError, "could not validate hero titles") return } valid := map[string]heroAdminItem{} for _, raw := range items { if item, ok := adminHeroItem(raw); ok { valid[item.ID] = item } } for name, placement := range placementPolicies { for _, id := range placement.PinnedItemIDs { item, ok := valid[id] if !ok || (name == store.HeroPlacementMovies && !strings.EqualFold(item.Type, "Movie")) || (name == store.HeroPlacementTVShows && !strings.EqualFold(item.Type, "Series")) { writeError(w, http.StatusBadRequest, "every pinned item must match its hero placement") return } } } for _, schedule := range request.Schedules { frequency := strings.ToLower(strings.TrimSpace(schedule.Frequency)) if frequency != "" && frequency != "once" && frequency != "daily" && frequency != "weekly" { writeError(w, http.StatusBadRequest, "hero schedule frequency must be once, daily or weekly") return } if frequency == "daily" || frequency == "weekly" { start, startErr := time.Parse("15:04", strings.TrimSpace(schedule.StartTime)) end, endErr := time.Parse("15:04", strings.TrimSpace(schedule.EndTime)) if startErr != nil || endErr != nil || start.Equal(end) { writeError(w, http.StatusBadRequest, "repeating hero schedules need different start and end times") return } if frequency == "weekly" && len(schedule.Weekdays) == 0 { writeError(w, http.StatusBadRequest, "weekly hero schedules need at least one day") return } } else if !schedule.EndAt.After(schedule.StartAt) { writeError(w, http.StatusBadRequest, "one-time hero schedules need an end after their start") return } item, ok := valid[strings.TrimSpace(schedule.ItemID)] if !ok { writeError(w, http.StatusBadRequest, "every scheduled hero must be a playable library film or series") return } placements := schedule.Placements if len(placements) == 0 { placements = []string{store.HeroPlacementHome} } for _, name := range placements { if !store.ValidHeroPlacement(name) || (name == store.HeroPlacementMovies && !strings.EqualFold(item.Type, "Movie")) || (name == store.HeroPlacementTVShows && !strings.EqualFold(item.Type, "Series")) { writeError(w, http.StatusBadRequest, "every scheduled item must match its hero placement") return } } } policy := store.HeroPolicy{Placements: placementPolicies, Schedules: request.Schedules} if err := s.store.SetHeroPolicy(r.Context(), policy); err != nil { s.loggerFor(r.Context()).Error("hero policy write failed", "error", err) writeError(w, http.StatusInternalServerError, "could not save hero policy") return } s.invalidateAllHomeCaches(r.Context()) writeJSON(w, http.StatusOK, map[string]any{"saved": true}) } func uniqueHeroIDs(ids []string) []string { seen := map[string]bool{} out := make([]string, 0, len(ids)) for _, id := range ids { id = strings.TrimSpace(id) if id == "" || seen[id] { continue } seen[id] = true out = append(out, id) } return out } func (s *Server) invalidateAllHomeCaches(ctx context.Context) { users, err := s.store.KnownUsers(ctx) if err != nil { s.loggerFor(ctx).Warn("hero cache invalidation could not list users", "error", err) return } for _, user := range users { if err := s.cache.InvalidateUser(ctx, user.ID); err != nil { s.loggerFor(ctx).Warn("hero cache invalidation failed", "user", user.ID, "error", err) } } }