Release Memby 0.2.64

This commit is contained in:
ponzischeme89
2026-08-14 13:32:14 +12:00
parent ad0c74f880
commit 05d95f51cd
33 changed files with 1142 additions and 112 deletions
+19 -15
View File
@@ -34,21 +34,25 @@ type Event = store.AdminEvent
// the console's type filter is built from what has actually been published, and an
// integration configured to send "everything" sends it.
const (
TypeLogin = "auth.login"
TypeLoginFailed = "auth.login_failed"
TypeLogout = "auth.logout"
TypeDeviceRegistered = "device.registered"
TypeDeviceRemoved = "device.removed"
TypeDeviceRenamed = "device.renamed"
TypeAdminSignIn = "admin.sign_in"
TypeServerStarted = "server.started"
TypeMaintenanceChanged = "server.maintenance"
TypeTaskCompleted = "task.completed"
TypeTaskFailed = "task.failed"
TypeIntegrationFailed = "integration.failed"
TypeLibrarySync = "library.sync"
TypeEmbyUnreachable = "emby.unreachable"
TypeEmbyRecovered = "emby.recovered"
TypeLogin = "auth.login"
TypeLoginFailed = "auth.login_failed"
TypeLogout = "auth.logout"
TypeDeviceRegistered = "device.registered"
TypeDeviceRemoved = "device.removed"
TypeDeviceRenamed = "device.renamed"
TypeAdminSignIn = "admin.sign_in"
TypeServerStarted = "server.started"
TypeMaintenanceChanged = "server.maintenance"
TypeTaskCompleted = "task.completed"
TypeTaskFailed = "task.failed"
TypeIntegrationFailed = "integration.failed"
TypeLibrarySync = "library.sync"
TypeEmbyUnreachable = "emby.unreachable"
TypeEmbyRecovered = "emby.recovered"
TypeMediaReportCreated = "media.report.created"
TypeReplacementRequested = "media.replacement.requested"
TypeReplacementFailed = "media.replacement.failed"
TypeMediaReportResolved = "media.report.resolved"
)
// Severity re-exports the store's vocabulary so a publisher needs only this package.
+2
View File
@@ -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))
+2
View File
@@ -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))
+18 -4
View File
@@ -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
+182
View File
@@ -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
}
+98 -25
View File
@@ -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")
+12
View File
@@ -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 {
+168
View File
@@ -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)
}
+11
View File
@@ -53,6 +53,17 @@ type Movie struct {
MinimumAvailability string `json:"minimumAvailability,omitempty"`
}
// SearchMovie asks Radarr to search exactly one tracked film. It does not delete or
// unmonitor the existing file; Radarr retains it until its normal import policy wins.
func (c *Client) SearchMovie(ctx context.Context, movieID int) error {
if movieID <= 0 {
return fmt.Errorf("radarr: invalid movie id")
}
return c.post(ctx, "/api/v3/command", map[string]any{
"name": "MoviesSearch", "movieIds": []int{movieID},
}, &struct{}{})
}
type RootFolder struct {
Path string `json:"path"`
}
+12
View File
@@ -134,6 +134,18 @@ type Episode struct {
EpisodeFile *EpisodeFile `json:"episodeFile"`
}
// SearchEpisode asks Sonarr to search exactly one episode. It deliberately uses the
// episode ids command rather than a series command, so a replacement cannot search a
// season or the show's backlog.
func (c *Client) SearchEpisode(ctx context.Context, episodeID int) error {
if episodeID <= 0 {
return fmt.Errorf("sonarr: invalid episode id")
}
return c.post(ctx, "/api/v3/command", map[string]any{
"name": "EpisodeSearch", "episodeIds": []int{episodeID},
}, &struct{}{})
}
type APIError struct {
StatusCode int
Body string
+84
View File
@@ -0,0 +1,84 @@
package store
import (
"context"
"fmt"
"time"
)
type MediaReport struct {
ID int64 `json:"id"`
EmbyItemID string `json:"embyItemId"`
MediaType string `json:"mediaType"`
Title string `json:"title"`
SeriesTitle string `json:"seriesTitle,omitempty"`
SeasonNumber int `json:"seasonNumber,omitempty"`
EpisodeNumber int `json:"episodeNumber,omitempty"`
Reason string `json:"reason"`
Comment string `json:"comment,omitempty"`
ReportedByUserID string `json:"reportedByUserId"`
ReportedByUsername string `json:"reportedByUsername"`
ReportedByDevice string `json:"reportedByDevice,omitempty"`
ReplacementRequested bool `json:"replacementRequested"`
ReplacementStatus string `json:"replacementStatus,omitempty"`
Status string `json:"status"`
ArrItemID int `json:"arrItemId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Actions []MediaReportAction `json:"actions,omitempty"`
}
type MediaReportAction struct {
ID int64 `json:"id"`
Action string `json:"action"`
Detail string `json:"detail,omitempty"`
Actor string `json:"actor,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
func (s *Store) CreateMediaReport(ctx context.Context, report MediaReport) (MediaReport, error) {
err := s.pool.QueryRow(ctx, `INSERT INTO media_reports
(emby_item_id, media_type, title, series_title, season_number, episode_number, reason, comment,
reported_by_user_id, reported_by_username, reported_by_device, replacement_requested)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING id, status, created_at, updated_at`, report.EmbyItemID, report.MediaType, report.Title,
report.SeriesTitle, report.SeasonNumber, report.EpisodeNumber, report.Reason, report.Comment,
report.ReportedByUserID, report.ReportedByUsername, report.ReportedByDevice, report.ReplacementRequested,
).Scan(&report.ID, &report.Status, &report.CreatedAt, &report.UpdatedAt)
if err != nil {
return report, fmt.Errorf("store: create media report: %w", err)
}
_, err = s.pool.Exec(ctx, `INSERT INTO media_report_actions (report_id, action, actor) VALUES ($1, 'reported', $2)`, report.ID, report.ReportedByUsername)
if err != nil {
return report, fmt.Errorf("store: record media report action: %w", err)
}
return report, nil
}
func (s *Store) MediaReports(ctx context.Context) ([]MediaReport, error) {
rows, err := s.pool.Query(ctx, `SELECT id, emby_item_id, media_type, title, series_title, season_number, episode_number, reason, comment,
reported_by_user_id, reported_by_username, reported_by_device, replacement_requested, replacement_status, status, arr_item_id, created_at, updated_at
FROM media_reports ORDER BY created_at DESC`)
if err != nil {
return nil, fmt.Errorf("store: list media reports: %w", err)
}
defer rows.Close()
reports := []MediaReport{}
for rows.Next() {
var r MediaReport
if err := rows.Scan(&r.ID, &r.EmbyItemID, &r.MediaType, &r.Title, &r.SeriesTitle, &r.SeasonNumber, &r.EpisodeNumber, &r.Reason, &r.Comment, &r.ReportedByUserID, &r.ReportedByUsername, &r.ReportedByDevice, &r.ReplacementRequested, &r.ReplacementStatus, &r.Status, &r.ArrItemID, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, fmt.Errorf("store: scan media report: %w", err)
}
reports = append(reports, r)
}
return reports, rows.Err()
}
func (s *Store) SetMediaReportStatus(ctx context.Context, id int64, status, actor string) error {
_, err := s.pool.Exec(ctx, `UPDATE media_reports SET status=$2, updated_at=now() WHERE id=$1`, id, status)
if err != nil {
return fmt.Errorf("store: update media report: %w", err)
}
_, err = s.pool.Exec(ctx, `INSERT INTO media_report_actions (report_id, action, actor) VALUES ($1,$2,$3)`, id, status, actor)
return err
}
+39
View File
@@ -657,3 +657,42 @@ CREATE TABLE IF NOT EXISTS integration_deliveries (
CREATE INDEX IF NOT EXISTS integration_deliveries_idx
ON integration_deliveries (integration_id, attempted_at DESC);
-- A viewer's report about one concrete Emby movie or episode. A replacement is a
-- separate state on the report so an ordinary playback complaint can never start a
-- download. The uniqueness constraint is the first duplicate guard: one open workflow
-- owns an item until an operator resolves or dismisses it.
CREATE TABLE IF NOT EXISTS media_reports (
id BIGSERIAL PRIMARY KEY,
emby_item_id TEXT NOT NULL,
media_type TEXT NOT NULL, -- movie | episode
title TEXT NOT NULL,
series_title TEXT NOT NULL DEFAULT '',
season_number INT NOT NULL DEFAULT 0,
episode_number INT NOT NULL DEFAULT 0,
reason TEXT NOT NULL,
comment TEXT NOT NULL DEFAULT '',
reported_by_user_id TEXT NOT NULL,
reported_by_username TEXT NOT NULL,
reported_by_device TEXT NOT NULL DEFAULT '',
replacement_requested BOOLEAN NOT NULL DEFAULT false,
replacement_status TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'new',
arr_item_id INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS media_reports_open_item_idx
ON media_reports (emby_item_id) WHERE status IN ('new', 'acknowledged', 'replacement_requested', 'downloading');
CREATE INDEX IF NOT EXISTS media_reports_created_idx ON media_reports (created_at DESC);
CREATE TABLE IF NOT EXISTS media_report_actions (
id BIGSERIAL PRIMARY KEY,
report_id BIGINT NOT NULL REFERENCES media_reports(id) ON DELETE CASCADE,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
actor TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS media_report_actions_report_idx
ON media_report_actions (report_id, created_at ASC);
+101 -13
View File
@@ -85,24 +85,78 @@ const MDBListSettingsKey = "mdblist_settings"
// HeroPolicy stores only Emby ids and the optional prime-card copy. Names and artwork
// remain library data, so a metadata correction appears without rewriting operator policy.
type HeroPolicy struct {
PinnedItemIDs []string `json:"pinnedItemIds"`
LegacyPinnedMovieIDs []string `json:"pinnedMovieIds,omitempty"`
PrimeSubtitle string `json:"primeSubtitle"`
UpdatedAt time.Time `json:"updatedAt"`
Schedules []HeroSchedule `json:"schedules"`
PinnedItemIDs []string `json:"pinnedItemIds"`
LegacyPinnedMovieIDs []string `json:"pinnedMovieIds,omitempty"`
PrimeSubtitle string `json:"primeSubtitle"`
Placements map[string]HeroPlacementPolicy `json:"placements,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
Schedules []HeroSchedule `json:"schedules"`
}
// HeroPlacementPolicy is one independently resolved spotlight. Keeping the placement as
// a map key means another section can be added without changing the stored document.
type HeroPlacementPolicy struct {
PinnedItemIDs []string `json:"pinnedItemIds"`
PrimeSubtitle string `json:"primeSubtitle"`
}
// HeroSchedule is resolved by the gateway for every home response. Times are UTC RFC3339;
// weekdays use the local calendar day (Sunday=0) and an empty list means every day.
type HeroSchedule struct {
ID string `json:"id"`
ItemID string `json:"itemId"`
StartAt time.Time `json:"startAt"`
EndAt time.Time `json:"endAt"`
Weekdays []int `json:"weekdays,omitempty"`
Priority int `json:"priority"`
UserID string `json:"userId,omitempty"`
Enabled bool `json:"enabled"`
ID string `json:"id"`
ItemID string `json:"itemId"`
StartAt time.Time `json:"startAt"`
EndAt time.Time `json:"endAt"`
Weekdays []int `json:"weekdays,omitempty"`
Priority int `json:"priority"`
UserID string `json:"userId,omitempty"`
Enabled bool `json:"enabled"`
Placements []string `json:"placements,omitempty"`
}
const (
HeroPlacementHome = "home"
HeroPlacementMovies = "movies"
HeroPlacementTVShows = "tv_shows"
)
func ValidHeroPlacement(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case HeroPlacementHome, HeroPlacementMovies, HeroPlacementTVShows:
return true
default:
return false
}
}
func normaliseHeroPlacementPolicy(policy HeroPlacementPolicy) HeroPlacementPolicy {
seen := map[string]bool{}
ids := make([]string, 0, min(len(policy.PinnedItemIDs), 4))
for _, id := range policy.PinnedItemIDs {
id = strings.TrimSpace(id)
if id == "" || seen[id] || len(ids) == 4 {
continue
}
seen[id] = true
ids = append(ids, id)
}
policy.PinnedItemIDs = ids
policy.PrimeSubtitle = strings.TrimSpace(policy.PrimeSubtitle)
if runes := []rune(policy.PrimeSubtitle); len(runes) > 160 {
policy.PrimeSubtitle = string(runes[:160])
}
return policy
}
func (policy HeroPolicy) Placement(name string) HeroPlacementPolicy {
name = strings.ToLower(strings.TrimSpace(name))
if placement, ok := policy.Placements[name]; ok {
return placement
}
if name == HeroPlacementHome {
return HeroPlacementPolicy{PinnedItemIDs: policy.PinnedItemIDs, PrimeSubtitle: policy.PrimeSubtitle}
}
return HeroPlacementPolicy{PinnedItemIDs: []string{}}
}
func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
@@ -126,6 +180,27 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
if len(runes) > 160 {
policy.PrimeSubtitle = string(runes[:160])
}
if policy.Placements == nil {
policy.Placements = map[string]HeroPlacementPolicy{}
}
if _, exists := policy.Placements[HeroPlacementHome]; !exists {
policy.Placements[HeroPlacementHome] = HeroPlacementPolicy{PinnedItemIDs: policy.PinnedItemIDs, PrimeSubtitle: policy.PrimeSubtitle}
}
cleanPlacements := make(map[string]HeroPlacementPolicy, len(policy.Placements))
for name, placement := range policy.Placements {
name = strings.ToLower(strings.TrimSpace(name))
if ValidHeroPlacement(name) {
cleanPlacements[name] = normaliseHeroPlacementPolicy(placement)
}
}
for _, name := range []string{HeroPlacementHome, HeroPlacementMovies, HeroPlacementTVShows} {
if _, exists := cleanPlacements[name]; !exists {
cleanPlacements[name] = HeroPlacementPolicy{PinnedItemIDs: []string{}}
}
}
policy.Placements = cleanPlacements
home := policy.Placement(HeroPlacementHome)
policy.PinnedItemIDs, policy.PrimeSubtitle = home.PinnedItemIDs, home.PrimeSubtitle
cleanSchedules := make([]HeroSchedule, 0, len(policy.Schedules))
seenSchedules := map[string]bool{}
for _, schedule := range policy.Schedules {
@@ -149,6 +224,19 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
}
}
schedule.Weekdays = weekdays
placements := make([]string, 0, len(schedule.Placements))
seenPlacements := map[string]bool{}
for _, placement := range schedule.Placements {
placement = strings.ToLower(strings.TrimSpace(placement))
if ValidHeroPlacement(placement) && !seenPlacements[placement] {
seenPlacements[placement] = true
placements = append(placements, placement)
}
}
if len(placements) == 0 {
placements = []string{HeroPlacementHome}
}
schedule.Placements = placements
cleanSchedules = append(cleanSchedules, schedule)
}
policy.Schedules = cleanSchedules
+35 -1
View File
@@ -1,6 +1,9 @@
package store
import "testing"
import (
"testing"
"time"
)
func TestRequestPolicyAllowsOnlyListedUsers(t *testing.T) {
policy := RequestPolicy{AllowedUserIDs: []string{"user-2"}}
@@ -50,6 +53,37 @@ func TestHeroPolicyReadsTheEarlierMovieOnlyShape(t *testing.T) {
}
}
func TestHeroPolicyMigratesHomeAndKeepsPlacementsIndependent(t *testing.T) {
got := normalizeHeroPolicy(HeroPolicy{
PinnedItemIDs: []string{"home-1"},
Placements: map[string]HeroPlacementPolicy{
HeroPlacementMovies: {PinnedItemIDs: []string{"film-1"}},
HeroPlacementTVShows: {PinnedItemIDs: []string{"series-1"}},
},
})
if got.Placement(HeroPlacementHome).PinnedItemIDs[0] != "home-1" {
t.Fatalf("legacy Home policy was not migrated: %+v", got.Placements)
}
if got.Placement(HeroPlacementMovies).PinnedItemIDs[0] != "film-1" ||
got.Placement(HeroPlacementTVShows).PinnedItemIDs[0] != "series-1" {
t.Fatalf("placement policies crossed: %+v", got.Placements)
}
}
func TestHeroSchedulesDefaultToHomeAndNormalisePlacementNames(t *testing.T) {
now := time.Now()
got := normalizeHeroPolicy(HeroPolicy{Schedules: []HeroSchedule{
{ID: "old", ItemID: "one", StartAt: now, EndAt: now.Add(time.Hour), Enabled: true},
{ID: "new", ItemID: "two", StartAt: now, EndAt: now.Add(time.Hour), Enabled: true, Placements: []string{" MOVIES ", "movies", "bad"}},
}})
if len(got.Schedules[0].Placements) != 1 || got.Schedules[0].Placements[0] != HeroPlacementHome {
t.Fatalf("old schedule placements = %v", got.Schedules[0].Placements)
}
if len(got.Schedules[1].Placements) != 1 || got.Schedules[1].Placements[0] != HeroPlacementMovies {
t.Fatalf("new schedule placements = %v", got.Schedules[1].Placements)
}
}
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
defaults := DefaultMDBListSettings()
if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 {