2026-08-11 23:41:10 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
2026-08-15 09:23:26 +12:00
|
|
|
"time"
|
2026-08-11 23:41:10 +12:00
|
|
|
|
|
|
|
|
"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 13:32:14 +12:00
|
|
|
PinnedItems []heroAdminItem `json:"pinnedItems"`
|
|
|
|
|
Items []heroAdminItem `json:"items"`
|
|
|
|
|
PrimeSubtitle string `json:"primeSubtitle"`
|
|
|
|
|
Placements map[string]heroAdminPlacement `json:"placements"`
|
|
|
|
|
Schedules []store.HeroSchedule `json:"schedules"`
|
2026-08-15 09:23:26 +12:00
|
|
|
TimeZone string `json:"timeZone"`
|
2026-08-14 13:32:14 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type heroAdminPlacement struct {
|
|
|
|
|
PinnedItems []heroAdminItem `json:"pinnedItems"`
|
|
|
|
|
PrimeSubtitle string `json:"primeSubtitle"`
|
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)
|
2026-08-14 13:32:14 +12:00
|
|
|
return heroAdminPolicy{PinnedItems: []heroAdminItem{}, Placements: map[string]heroAdminPlacement{}}
|
2026-08-11 23:41:10 +12:00
|
|
|
}
|
2026-08-14 13:32:14 +12:00
|
|
|
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)
|
2026-08-11 23:41:10 +12:00
|
|
|
if err != nil {
|
|
|
|
|
s.loggerFor(ctx).Warn("pinned hero titles unavailable to admin", "error", err)
|
2026-08-14 13:32:14 +12:00
|
|
|
return heroAdminPolicy{PinnedItems: []heroAdminItem{}, Placements: map[string]heroAdminPlacement{}}
|
2026-08-11 23:41:10 +12:00
|
|
|
}
|
|
|
|
|
byID := make(map[string]heroAdminItem, len(items))
|
|
|
|
|
for _, raw := range items {
|
|
|
|
|
if item, ok := adminHeroItem(raw); ok {
|
|
|
|
|
byID[item.ID] = item
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out := heroAdminPolicy{
|
2026-08-14 13:32:14 +12:00
|
|
|
Placements: make(map[string]heroAdminPlacement, len(policy.Placements)),
|
|
|
|
|
Items: []heroAdminItem{},
|
|
|
|
|
Schedules: policy.Schedules,
|
2026-08-15 09:23:26 +12:00
|
|
|
TimeZone: heroScheduleTimeZone(s.cfg.RadarrLocation),
|
2026-08-11 23:41:10 +12:00
|
|
|
}
|
2026-08-14 13:32:14 +12:00
|
|
|
for _, id := range allIDs {
|
2026-08-11 23:41:10 +12:00
|
|
|
if item, ok := byID[id]; ok {
|
2026-08-14 13:32:14 +12:00
|
|
|
out.Items = append(out.Items, item)
|
2026-08-11 23:41:10 +12:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-14 13:32:14 +12:00
|
|
|
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
|
2026-08-11 23:41:10 +12:00
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
func heroScheduleTimeZone(location *time.Location) string {
|
|
|
|
|
if location == nil {
|
|
|
|
|
location = time.Local
|
|
|
|
|
}
|
|
|
|
|
return location.String()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:09:17 +12:00
|
|
|
// 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.
|
2026-08-11 23:41:10 +12:00
|
|
|
func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
|
2026-08-17 19:09:17 +12:00
|
|
|
term := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
|
|
|
items, err := s.store.SearchLibrary(r.Context(), term, heroSearchLimit)
|
2026-08-11 23:41:10 +12:00
|
|
|
if err != nil {
|
|
|
|
|
s.loggerFor(r.Context()).Error("hero library search failed", "error", err)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not search the library")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-17 19:09:17 +12:00
|
|
|
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)
|
2026-08-11 23:41:10 +12:00
|
|
|
}
|
2026-08-17 19:09:17 +12:00
|
|
|
items = append(items, found...)
|
2026-08-11 23:41:10 +12:00
|
|
|
}
|
2026-08-17 19:09:17 +12:00
|
|
|
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
|
2026-08-11 23:41:10 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var request struct {
|
2026-08-14 13:32:14 +12:00
|
|
|
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"`
|
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
|
|
|
|
|
}
|
2026-08-14 13:32:14 +12:00
|
|
|
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},
|
|
|
|
|
}
|
2026-08-11 23:41:10 +12:00
|
|
|
}
|
2026-08-14 13:32:14 +12:00
|
|
|
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))
|
2026-08-11 23:41:10 +12:00
|
|
|
if err != nil {
|
|
|
|
|
s.loggerFor(r.Context()).Error("hero title validation failed", "error", err)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not validate hero titles")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-14 13:32:14 +12:00
|
|
|
valid := map[string]heroAdminItem{}
|
2026-08-11 23:41:10 +12:00
|
|
|
for _, raw := range items {
|
|
|
|
|
if item, ok := adminHeroItem(raw); ok {
|
2026-08-14 13:32:14 +12:00
|
|
|
valid[item.ID] = item
|
2026-08-11 23:41:10 +12:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-14 13:32:14 +12:00
|
|
|
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 {
|
2026-08-15 09:23:26 +12:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-14 13:32:14 +12:00
|
|
|
item, ok := valid[strings.TrimSpace(schedule.ItemID)]
|
|
|
|
|
if !ok {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "every scheduled hero must be a playable library film or series")
|
2026-08-11 23:41:10 +12:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-14 13:32:14 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-11 23:41:10 +12:00
|
|
|
}
|
2026-08-14 13:32:14 +12:00
|
|
|
policy := store.HeroPolicy{Placements: placementPolicies, 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
|
|
|
|
|
}
|
2026-08-20 07:54:03 +12:00
|
|
|
// No cache sweep. The hero revision is part of the home and section-hero cache keys,
|
|
|
|
|
// so the entries built under the policy just replaced are already unreachable and age
|
|
|
|
|
// out on their own TTL; this only drops *this* instance's copy of the document, which
|
|
|
|
|
// is what makes the operator's own next read the answer they just saved rather than
|
|
|
|
|
// the one they replaced.
|
|
|
|
|
//
|
|
|
|
|
// It used to call InvalidateUser for every account in the house, which threw away
|
|
|
|
|
// every cached item lookup, image and row the household had in order to change four
|
|
|
|
|
// cards — so a hero edit made the next launcher on every set rebuild from Emby.
|
|
|
|
|
s.heroPolicy.invalidate()
|
2026-08-11 23:41:10 +12:00
|
|
|
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
|
|
|
|
|
}
|