Release Memby 0.2.64
This commit is contained in:
@@ -59,6 +59,8 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
||||
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
||||
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
||||
mux.Handle("GET /admin/api/media-reports", s.adminAuth(s.handleAdminMediaReports))
|
||||
mux.Handle("POST /admin/api/media-reports/{id}/status", s.adminAuth(s.handleAdminMediaReportStatus))
|
||||
mux.Handle("GET /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
|
||||
mux.Handle("POST /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
|
||||
mux.Handle("GET /admin/api/radarr-request-policy", s.adminAuth(s.handleAdminRadarrRequestPolicy))
|
||||
|
||||
@@ -190,6 +190,7 @@ func (s *Server) Routes() http.Handler {
|
||||
v1.Handle("DELETE /v1/auth/devices/{deviceID}", s.authed(s.handleDeleteDevice))
|
||||
|
||||
v1.Handle("GET /v1/home", s.authed(s.handleHome))
|
||||
v1.Handle("GET /v1/heroes/active", s.authed(s.handleActiveHero))
|
||||
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
|
||||
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
|
||||
// A genre is browsed, not searched: the chip is a filter and this is the route that
|
||||
@@ -252,6 +253,7 @@ func (s *Server) Routes() http.Handler {
|
||||
v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers))
|
||||
v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer))
|
||||
v1.Handle("POST /v1/items/{id}/trailers/report", s.authed(s.handleTrailerReport))
|
||||
v1.Handle("POST /v1/items/{id}/report", s.authed(s.handleMediaReport))
|
||||
v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro))
|
||||
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
|
||||
v1.Handle("GET /v1/items/{id}/trickplay/{frame}", s.authed(s.handleTrickplayFrame))
|
||||
|
||||
@@ -678,10 +678,11 @@ func (s *Server) heroRow(
|
||||
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
|
||||
policy = store.HeroPolicy{}
|
||||
}
|
||||
pinned := s.pinnedHeroCandidates(ctx, policy.PinnedItemIDs)
|
||||
placementPolicy := policy.Placement(store.HeroPlacementHome)
|
||||
pinned := s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs)
|
||||
// Manual pins always lead. Schedules resolve on the gateway (never on a television),
|
||||
// then the existing automatic/release-aware selection fills any remaining places.
|
||||
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, userID, now, location)
|
||||
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, store.HeroPlacementHome, userID, now, location)
|
||||
scheduled := s.pinnedHeroCandidates(ctx, scheduledIDs)
|
||||
// Rank a pool, then draw the row out of it. Ranking straight to the row's length is
|
||||
// what made the hero the same four cards for a week — see rotateHeroCandidates.
|
||||
@@ -700,7 +701,7 @@ func (s *Server) heroRow(
|
||||
items = append(items, injectHeroFields(
|
||||
candidate.Item,
|
||||
heroLabel(candidate, now),
|
||||
heroReasonForPosition(candidate, index, policy.PrimeSubtitle, now, location),
|
||||
heroReasonForPosition(candidate, index, placementPolicy.PrimeSubtitle, now, location),
|
||||
))
|
||||
}
|
||||
return &recommend.Row{
|
||||
@@ -750,7 +751,7 @@ func mergePinnedHeroCandidates(
|
||||
return out
|
||||
}
|
||||
|
||||
func activeHeroScheduleIDs(schedules []store.HeroSchedule, userID string, now time.Time, location *time.Location) []string {
|
||||
func activeHeroScheduleIDs(schedules []store.HeroSchedule, placement, userID string, now time.Time, location *time.Location) []string {
|
||||
type active struct {
|
||||
id string
|
||||
priority int
|
||||
@@ -761,6 +762,19 @@ func activeHeroScheduleIDs(schedules []store.HeroSchedule, userID string, now ti
|
||||
if !schedule.Enabled || (schedule.UserID != "" && schedule.UserID != userID) || now.Before(schedule.StartAt) || !now.Before(schedule.EndAt) {
|
||||
continue
|
||||
}
|
||||
matchesPlacement := false
|
||||
if len(schedule.Placements) == 0 && placement == store.HeroPlacementHome {
|
||||
matchesPlacement = true
|
||||
}
|
||||
for _, candidate := range schedule.Placements {
|
||||
if candidate == placement {
|
||||
matchesPlacement = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matchesPlacement {
|
||||
continue
|
||||
}
|
||||
if len(schedule.Weekdays) > 0 {
|
||||
weekday := int(now.In(location).Weekday())
|
||||
found := false
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
type activeHeroResponse struct {
|
||||
Placement string `json:"placement"`
|
||||
Source string `json:"source"`
|
||||
Rows []recommend.Row `json:"rows"`
|
||||
}
|
||||
|
||||
// handleActiveHero gives every section the same server-owned resolver as Home. The client
|
||||
// supplies only a placement; schedules, priorities, pins and ranking remain gateway data.
|
||||
func (s *Server) handleActiveHero(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
placement := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("placement")))
|
||||
if !store.ValidHeroPlacement(placement) {
|
||||
writeError(w, http.StatusBadRequest, "placement must be home, movies or tv_shows")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
location := s.cfg.RadarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
key := cache.UserKey(sess.EmbyUserID, "hero:active:v1:"+placement+":"+heroRotationSlot(now, location))
|
||||
if raw, err := s.cache.Get(r.Context(), key); err == nil {
|
||||
w.Header().Set("X-Memby-Cache", "hit")
|
||||
writeRaw(w, http.StatusOK, raw)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := s.resolveActiveHero(r.Context(), sess, placement, now)
|
||||
if err != nil {
|
||||
s.writeUpstreamError(r.Context(), w, err, "could not resolve the section hero")
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not build the section hero")
|
||||
return
|
||||
}
|
||||
if err := s.cache.Set(r.Context(), key, raw, s.cfg.SearchTTL); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("section hero cache write failed", "placement", placement, "error", err)
|
||||
}
|
||||
w.Header().Set("X-Memby-Cache", "miss")
|
||||
writeRaw(w, http.StatusOK, raw)
|
||||
}
|
||||
|
||||
func (s *Server) resolveActiveHero(ctx context.Context, sess store.Session, placement string, now time.Time) (activeHeroResponse, error) {
|
||||
itemType := "Movie"
|
||||
if placement == store.HeroPlacementTVShows {
|
||||
itemType = "Series"
|
||||
}
|
||||
if placement == store.HeroPlacementHome {
|
||||
itemType = "Movie,Series"
|
||||
}
|
||||
result, err := s.emby.Items(ctx, credentials(sess), rowParams(url.Values{
|
||||
"IncludeItemTypes": {itemType}, "Recursive": {"true"}, "Limit": {"64"},
|
||||
"SortBy": {"DateCreated,PremiereDate,SortName"}, "SortOrder": {"Descending"},
|
||||
}, fieldsRow))
|
||||
if err != nil {
|
||||
return activeHeroResponse{}, err
|
||||
}
|
||||
s.decorateItemRatings(ctx, result.Items)
|
||||
rows := []recommend.Row{{ID: "hero-candidates-" + placement, Kind: "catalogue", Items: result.Items}}
|
||||
|
||||
policy, err := s.store.HeroPolicy(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("section hero policy unavailable", "placement", placement, "error", err)
|
||||
policy = store.HeroPolicy{}
|
||||
}
|
||||
placementPolicy := policy.Placement(placement)
|
||||
pinned := filterHeroPlacement(s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs), placement)
|
||||
location := s.cfg.RadarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, placement, sess.EmbyUserID, now, location)
|
||||
scheduled := filterHeroPlacement(s.pinnedHeroCandidates(ctx, scheduledIDs), placement)
|
||||
|
||||
var candidates []heroCandidate
|
||||
if placement == store.HeroPlacementTVShows {
|
||||
candidates = append(s.heroPremiereCandidates(ctx, now), heroSeriesCandidates(rows)...)
|
||||
candidates = filterHeroPlacement(candidates, placement)
|
||||
} else if placement == store.HeroPlacementMovies {
|
||||
candidates = s.heroMoviePlacementCandidates(ctx, rows, now)
|
||||
} else {
|
||||
candidates = s.heroCandidates(ctx, rows, now)
|
||||
}
|
||||
pool := rankHeroCandidates(candidates, now, heroPoolLimit)
|
||||
organic := rotateHeroCandidates(pool, heroVariationSeed(sess.EmbyUserID, heroRotationSlot(now, location)+":"+placement), heroRowLimit)
|
||||
ranked := mergePinnedHeroCandidates(append(pinned, scheduled...), candidates, organic, heroRowLimit)
|
||||
|
||||
source := "automatic"
|
||||
if len(pinned) > 0 {
|
||||
source = "manual"
|
||||
} else if len(scheduled) > 0 {
|
||||
source = "scheduled"
|
||||
}
|
||||
response := activeHeroResponse{Placement: placement, Source: source, Rows: []recommend.Row{}}
|
||||
if len(ranked) == 0 {
|
||||
response.Source = "fallback"
|
||||
return response, nil
|
||||
}
|
||||
items := make([]json.RawMessage, 0, len(ranked))
|
||||
for index, candidate := range ranked {
|
||||
items = append(items, injectHeroFields(candidate.Item, heroLabel(candidate, now), heroReasonForPosition(candidate, index, placementPolicy.PrimeSubtitle, now, location)))
|
||||
}
|
||||
response.Rows = append(response.Rows, recommend.Row{ID: "hero-" + placement, Title: "Featured", Kind: heroRowKind, Items: items})
|
||||
s.loggerFor(ctx).Debug("section hero resolved", "placement", placement, "source", source, "items", len(items))
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Movies needs Radarr's real availability date, but never needs to wait for Sonarr. Home
|
||||
// intentionally combines both; keeping this path narrow is what makes section preloading
|
||||
// inexpensive even when one integration is unavailable.
|
||||
func (s *Server) heroMoviePlacementCandidates(ctx context.Context, rows []recommend.Row, now time.Time) []heroCandidate {
|
||||
movies, facts := heroMovieCandidates(rows)
|
||||
var releases heroReleaseIndex
|
||||
var providers map[string]string
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); releases = s.heroReleaseIndex(ctx, now) }()
|
||||
go func() { defer wg.Done(); providers = s.heroProviderIDs(ctx, facts) }()
|
||||
wg.Wait()
|
||||
for index := range movies {
|
||||
fact := facts[movies[index].ID]
|
||||
if release, ok := releases.lookup(providers[movies[index].ID], fact.Name, heroYearOf(fact)); ok {
|
||||
movies[index].ReleasedAt = release.at
|
||||
movies[index].Estimated = release.estimated
|
||||
}
|
||||
}
|
||||
return movies
|
||||
}
|
||||
|
||||
func filterHeroPlacement(candidates []heroCandidate, placement string) []heroCandidate {
|
||||
out := make([]heroCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
isMovie := candidate.Kind == heroMovie
|
||||
if placement == store.HeroPlacementMovies && isMovie {
|
||||
out = append(out, candidate)
|
||||
}
|
||||
if placement == store.HeroPlacementTVShows && !isMovie {
|
||||
out = append(out, candidate)
|
||||
}
|
||||
if placement == store.HeroPlacementHome {
|
||||
out = append(out, candidate)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func heroSeriesCandidates(rows []recommend.Row) []heroCandidate {
|
||||
out := make([]heroCandidate, 0, heroCandidateLimit)
|
||||
seen := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
for _, raw := range row.Items {
|
||||
fact, ok := heroFactsOf(raw)
|
||||
if !ok || seen[fact.ID] || !fact.Playable || !strings.EqualFold(fact.Type, "Series") {
|
||||
continue
|
||||
}
|
||||
seen[fact.ID] = true
|
||||
rating, rated := heroRatingOf(raw)
|
||||
out = append(out, heroCandidate{ID: fact.ID, Name: fact.Name, Kind: heroSeries, Item: raw, ReleasedAt: fact.Premiere, Rating: rating, Rated: rated})
|
||||
if len(out) == heroCandidateLimit {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -17,9 +17,16 @@ type heroAdminItem struct {
|
||||
}
|
||||
|
||||
type heroAdminPolicy struct {
|
||||
PinnedItems []heroAdminItem `json:"pinnedItems"`
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
Schedules []store.HeroSchedule `json:"schedules"`
|
||||
PinnedItems []heroAdminItem `json:"pinnedItems"`
|
||||
Items []heroAdminItem `json:"items"`
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
Placements map[string]heroAdminPlacement `json:"placements"`
|
||||
Schedules []store.HeroSchedule `json:"schedules"`
|
||||
}
|
||||
|
||||
type heroAdminPlacement struct {
|
||||
PinnedItems []heroAdminItem `json:"pinnedItems"`
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
}
|
||||
|
||||
func adminHeroItem(raw json.RawMessage) (heroAdminItem, bool) {
|
||||
@@ -35,12 +42,20 @@ 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{}}
|
||||
return heroAdminPolicy{PinnedItems: []heroAdminItem{}, Placements: map[string]heroAdminPlacement{}}
|
||||
}
|
||||
items, err := s.store.LibraryItemsByID(ctx, policy.PinnedItemIDs)
|
||||
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{}}
|
||||
return heroAdminPolicy{PinnedItems: []heroAdminItem{}, Placements: map[string]heroAdminPlacement{}}
|
||||
}
|
||||
byID := make(map[string]heroAdminItem, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -49,15 +64,26 @@ func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy {
|
||||
}
|
||||
}
|
||||
out := heroAdminPolicy{
|
||||
PinnedItems: make([]heroAdminItem, 0, len(policy.PinnedItemIDs)),
|
||||
PrimeSubtitle: policy.PrimeSubtitle,
|
||||
Schedules: policy.Schedules,
|
||||
Placements: make(map[string]heroAdminPlacement, len(policy.Placements)),
|
||||
Items: []heroAdminItem{},
|
||||
Schedules: policy.Schedules,
|
||||
}
|
||||
for _, id := range policy.PinnedItemIDs {
|
||||
for _, id := range allIDs {
|
||||
if item, ok := byID[id]; ok {
|
||||
out.PinnedItems = append(out.PinnedItems, item)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -79,38 +105,85 @@ func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
PinnedItemIDs []string `json:"pinnedItemIds"`
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
Schedules []store.HeroSchedule `json:"schedules"`
|
||||
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
|
||||
}
|
||||
ids := uniqueHeroIDs(request.PinnedItemIDs)
|
||||
if len(ids) > 4 {
|
||||
writeError(w, http.StatusBadRequest, "the hero can pin at most four titles")
|
||||
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},
|
||||
}
|
||||
}
|
||||
items, err := s.store.LibraryItemsByID(r.Context(), ids)
|
||||
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]bool{}
|
||||
valid := map[string]heroAdminItem{}
|
||||
for _, raw := range items {
|
||||
if item, ok := adminHeroItem(raw); ok {
|
||||
valid[item.ID] = true
|
||||
valid[item.ID] = item
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !valid[id] {
|
||||
writeError(w, http.StatusBadRequest, "every pinned item must be a playable library film or series")
|
||||
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 {
|
||||
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{PinnedItemIDs: ids, PrimeSubtitle: request.PrimeSubtitle, Schedules: request.Schedules}
|
||||
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")
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
var heroNow = time.Date(2026, 8, 7, 20, 0, 0, 0, time.UTC)
|
||||
@@ -630,6 +632,16 @@ func TestAdminHeroSearchAcceptsFilmsAndSeriesOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroPlacementFiltersNeverCrossMediaTypes(t *testing.T) {
|
||||
candidates := []heroCandidate{{ID: "film", Kind: heroMovie}, {ID: "show", Kind: heroSeries}}
|
||||
if got := heroIDs(filterHeroPlacement(candidates, store.HeroPlacementMovies)); !reflect.DeepEqual(got, []string{"film"}) {
|
||||
t.Fatalf("movie hero candidates = %v", got)
|
||||
}
|
||||
if got := heroIDs(filterHeroPlacement(candidates, store.HeroPlacementTVShows)); !reflect.DeepEqual(got, []string{"show"}) {
|
||||
t.Fatalf("television hero candidates = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func heroIDs(candidates []heroCandidate) []string {
|
||||
ids := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
var mediaReportReasons = map[string]bool{
|
||||
"playback_problem": true, "poor_video_quality": true, "poor_audio_quality": true,
|
||||
"audio_out_of_sync": true, "wrong_media": true, "missing_or_corrupt": true,
|
||||
"subtitles_problem": true, "file_stops": true, "request_better_copy": true, "other": true,
|
||||
}
|
||||
|
||||
type mediaReportPayload struct {
|
||||
Reason string `json:"reason"`
|
||||
Comment string `json:"comment"`
|
||||
RequestReplacement bool `json:"requestReplacement"`
|
||||
}
|
||||
type reportItem struct {
|
||||
Type string `json:"Type"`
|
||||
Name string `json:"Name"`
|
||||
SeriesName string `json:"SeriesName"`
|
||||
SeriesID string `json:"SeriesId"`
|
||||
ParentIndexNumber int `json:"ParentIndexNumber"`
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
ProviderIDs map[string]string `json:"ProviderIds"`
|
||||
}
|
||||
|
||||
func (s *Server) handleMediaReport(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
if s.store == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "media reporting is unavailable")
|
||||
return
|
||||
}
|
||||
var request mediaReportPayload
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media report")
|
||||
return
|
||||
}
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
request.Comment = strings.TrimSpace(request.Comment)
|
||||
if !mediaReportReasons[request.Reason] || len(request.Comment) > 500 {
|
||||
writeError(w, http.StatusBadRequest, "choose a valid report category and keep the comment under 500 characters")
|
||||
return
|
||||
}
|
||||
itemID := r.PathValue("id")
|
||||
raw, err := s.emby.Item(r.Context(), credentials(sess), itemID, "ProviderIds,SeriesId,SeriesName,ParentIndexNumber,IndexNumber")
|
||||
if err != nil {
|
||||
s.writeUpstreamError(r.Context(), w, err, "could not identify the media item")
|
||||
return
|
||||
}
|
||||
var item reportItem
|
||||
if err := json.Unmarshal(raw, &item); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "could not read the media item")
|
||||
return
|
||||
}
|
||||
mediaType := strings.ToLower(item.Type)
|
||||
if mediaType != "movie" && mediaType != "episode" {
|
||||
writeError(w, http.StatusBadRequest, "only movies and episodes can be reported")
|
||||
return
|
||||
}
|
||||
report, err := s.store.CreateMediaReport(r.Context(), store.MediaReport{EmbyItemID: itemID, MediaType: mediaType, Title: item.Name, SeriesTitle: item.SeriesName, SeasonNumber: item.ParentIndexNumber, EpisodeNumber: item.IndexNumber, Reason: request.Reason, Comment: request.Comment, ReportedByUserID: sess.EmbyUserID, ReportedByUsername: sess.Username, ReportedByDevice: sess.DeviceName, ReplacementRequested: request.RequestReplacement})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, "this media item already has an open report")
|
||||
return
|
||||
}
|
||||
s.publishAdmin(r.Context(), adminevents.Event{Type: adminevents.TypeMediaReportCreated, Severity: adminevents.SeverityWarning, Title: "Media issue reported", Summary: fmt.Sprintf("%s reported %s with %s.", sess.Username, reportLabel(report), request.Reason), Link: "/admin/media-reports", Metadata: adminevents.Meta(map[string]any{"reportId": report.ID, "itemId": itemID})})
|
||||
if request.RequestReplacement {
|
||||
if !s.requestAllowed(r, sess) {
|
||||
s.loggerFor(r.Context()).Warn("replacement not permitted", "report", report.ID, "user", sess.Username)
|
||||
writeJSON(w, http.StatusCreated, report)
|
||||
return
|
||||
}
|
||||
if err := s.requestReplacement(r, report, item); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("media replacement request failed", "report", report.ID, "error", err)
|
||||
s.publishAdmin(r.Context(), adminevents.Event{Type: adminevents.TypeReplacementFailed, Severity: adminevents.SeverityWarning, Title: "Replacement search failed", Summary: fmt.Sprintf("%s needs an operator review: %v", reportLabel(report), err), Link: "/admin/media-reports"})
|
||||
} else {
|
||||
s.publishAdmin(r.Context(), adminevents.Event{Type: adminevents.TypeReplacementRequested, Severity: adminevents.SeverityInfo, Title: "Replacement requested", Summary: fmt.Sprintf("A new copy of %s was requested through %s.", reportLabel(report), map[string]string{"movie": "Radarr", "episode": "Sonarr"}[report.MediaType]), Link: "/admin/media-reports"})
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, report)
|
||||
}
|
||||
|
||||
func (s *Server) requestReplacement(r *http.Request, report store.MediaReport, item reportItem) error {
|
||||
ctx := r.Context()
|
||||
if report.MediaType == "movie" {
|
||||
if !s.radarrEnabled(ctx) {
|
||||
return fmt.Errorf("Radarr is unavailable")
|
||||
}
|
||||
tmdb, _ := strconv.Atoi(item.ProviderIDs["Tmdb"])
|
||||
movies, err := s.radarr.Movies(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, movie := range movies {
|
||||
if movie.TMDBID == tmdb && tmdb > 0 {
|
||||
return s.radarr.SearchMovie(ctx, movie.ID)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("movie is not tracked by Radarr")
|
||||
}
|
||||
if !s.sonarrEnabled(ctx) {
|
||||
return fmt.Errorf("Sonarr is unavailable")
|
||||
}
|
||||
tvdb, _ := strconv.Atoi(item.ProviderIDs["Tvdb"])
|
||||
series, err := s.sonarr.Series(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, show := range series {
|
||||
if show.TVDBID == tvdb && tvdb > 0 {
|
||||
episodes, err := s.sonarr.Episodes(ctx, show.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, episode := range episodes {
|
||||
if episode.SeasonNumber == item.ParentIndexNumber && episode.EpisodeNumber == item.IndexNumber {
|
||||
return s.sonarr.SearchEpisode(ctx, episode.ID)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("episode is not tracked by Sonarr")
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("series is not tracked by Sonarr")
|
||||
}
|
||||
|
||||
func reportLabel(r store.MediaReport) string {
|
||||
if r.MediaType == "episode" {
|
||||
return fmt.Sprintf("%s S%02dE%02d", r.SeriesTitle, r.SeasonNumber, r.EpisodeNumber)
|
||||
}
|
||||
return r.Title
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminMediaReports(w http.ResponseWriter, r *http.Request) {
|
||||
reports, err := s.store.MediaReports(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load media reports")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"reports": reports})
|
||||
}
|
||||
func (s *Server) handleAdminMediaReportStatus(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id < 1 {
|
||||
writeError(w, http.StatusBadRequest, "invalid report")
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if json.NewDecoder(r.Body).Decode(&request) != nil || !map[string]bool{"acknowledged": true, "resolved": true, "dismissed": true}[request.Status] {
|
||||
writeError(w, http.StatusBadRequest, "invalid report status")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetMediaReportStatus(r.Context(), id, request.Status, "administrator"); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not update report")
|
||||
return
|
||||
}
|
||||
if request.Status == "resolved" {
|
||||
s.publishAdmin(r.Context(), adminevents.Event{Type: adminevents.TypeMediaReportResolved, Title: "Media report resolved", Summary: fmt.Sprintf("Media report %d was resolved.", id), Link: "/admin/media-reports"})
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
Reference in New Issue
Block a user