2026-08-11 23:41:10 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"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 {
|
2026-08-14 09:40:03 +12:00
|
|
|
PinnedItems []heroAdminItem `json:"pinnedItems"`
|
|
|
|
|
PrimeSubtitle string `json:"primeSubtitle"`
|
|
|
|
|
Schedules []store.HeroSchedule `json:"schedules"`
|
2026-08-11 23:41:10 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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{}}
|
|
|
|
|
}
|
|
|
|
|
items, err := s.store.LibraryItemsByID(ctx, policy.PinnedItemIDs)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.loggerFor(ctx).Warn("pinned hero titles unavailable to admin", "error", err)
|
|
|
|
|
return heroAdminPolicy{PinnedItems: []heroAdminItem{}}
|
|
|
|
|
}
|
|
|
|
|
byID := make(map[string]heroAdminItem, len(items))
|
|
|
|
|
for _, raw := range items {
|
|
|
|
|
if item, ok := adminHeroItem(raw); ok {
|
|
|
|
|
byID[item.ID] = item
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out := heroAdminPolicy{
|
|
|
|
|
PinnedItems: make([]heroAdminItem, 0, len(policy.PinnedItemIDs)),
|
|
|
|
|
PrimeSubtitle: policy.PrimeSubtitle,
|
2026-08-14 09:40:03 +12:00
|
|
|
Schedules: policy.Schedules,
|
2026-08-11 23:41:10 +12:00
|
|
|
}
|
|
|
|
|
for _, id := range policy.PinnedItemIDs {
|
|
|
|
|
if item, ok := byID[id]; ok {
|
|
|
|
|
out.PinnedItems = append(out.PinnedItems, item)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
items, err := s.store.SearchLibrary(r.Context(), r.URL.Query().Get("q"), 20)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.loggerFor(r.Context()).Error("hero library search failed", "error", err)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not search the library")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
results := make([]heroAdminItem, 0, len(items))
|
|
|
|
|
for _, raw := range items {
|
|
|
|
|
if item, ok := adminHeroItem(raw); ok {
|
|
|
|
|
results = append(results, item)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": results})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var request struct {
|
2026-08-14 09:40:03 +12:00
|
|
|
PinnedItemIDs []string `json:"pinnedItemIds"`
|
|
|
|
|
PrimeSubtitle string `json:"primeSubtitle"`
|
|
|
|
|
Schedules []store.HeroSchedule `json:"schedules"`
|
2026-08-11 23:41:10 +12:00
|
|
|
}
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "invalid hero policy")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ids := uniqueHeroIDs(request.PinnedItemIDs)
|
|
|
|
|
if len(ids) > 4 {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "the hero can pin at most four titles")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
items, err := s.store.LibraryItemsByID(r.Context(), ids)
|
|
|
|
|
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]bool{}
|
|
|
|
|
for _, raw := range items {
|
|
|
|
|
if item, ok := adminHeroItem(raw); ok {
|
|
|
|
|
valid[item.ID] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, id := range ids {
|
|
|
|
|
if !valid[id] {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "every pinned item must be a playable library film or series")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-14 09:40:03 +12:00
|
|
|
policy := store.HeroPolicy{PinnedItemIDs: ids, PrimeSubtitle: request.PrimeSubtitle, Schedules: request.Schedules}
|
2026-08-11 23:41:10 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|