This commit is contained in:
ponzischeme89
2026-08-11 23:41:10 +12:00
parent 0e183cf560
commit 5fed3360c2
42 changed files with 1340 additions and 130 deletions
+4
View File
@@ -68,6 +68,8 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
mux.Handle("GET /admin/api/hero/search", s.adminAuth(s.handleAdminHeroSearch))
mux.Handle("POST /admin/api/hero-policy", s.adminAuth(s.handleAdminHeroPolicy))
mux.Handle("POST /admin/api/mdblist-settings", s.adminAuth(s.handleAdminMDBListSettings))
mux.Handle("POST /admin/api/subtitle-settings", s.adminAuth(s.handleAdminSubtitleSettings))
mux.Handle("POST /admin/api/subtitle-test", s.adminAuth(s.handleAdminSubtitleTest))
@@ -264,6 +266,7 @@ type adminStatus struct {
ForYouRunning bool `json:"forYouRunning"`
RequestPolicy store.RequestPolicy `json:"requestPolicy"`
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
HeroPolicy heroAdminPolicy `json:"heroPolicy"`
MDBList mdblistAdminSettings `json:"mdblist"`
Subtitles subtitleAdminSettings `json:"subtitles"`
Features featureResponse `json:"features"`
@@ -329,6 +332,7 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
}
return policy
}(),
HeroPolicy: s.heroAdminPolicy(ctx),
Subtitles: s.subtitleAdminSettings(ctx),
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
RequestUsers: requestUsers,
+30
View File
@@ -0,0 +1,30 @@
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="star" data-icon-tone="note">Pinned titles</h2>
<p class="card-note">Pinned films and series lead the four-card launcher grid in this order. Empty
places are filled by Membys existing mix of recent digital releases, premieres and
highly rated library titles. Pinning changes placement only; labels and reasons remain natural.</p>
</div>
<div id="hero-pins"></div>
<label class="field"><span>Prime-card subtitle</span>
<em>Optional wording under the large first card. Leave blank to use Membys natural release or rating reason.</em>
<input id="hero-prime-subtitle" type="text" maxlength="160"
placeholder="Leave blank for the automatic reason"></label>
<div class="card-foot">
<button class="primary" id="hero-save">Save hero</button>
<button id="hero-clear">Clear pins</button>
</div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="search" data-icon-tone="info">Find a title</h2>
<p class="card-note">Search the imported Emby catalogue. Up to four films or series can be pinned.</p>
</div>
<div class="row">
<label class="field"><span>Title</span>
<input id="hero-query" type="search" placeholder="Search films and television shows"></label>
<button id="hero-search">Search</button>
</div>
<div class="grid" id="hero-results"></div>
</section>
+75
View File
@@ -0,0 +1,75 @@
const { fmt, ui, $ } = Admin;
let pins = [];
let pinsDirty = false;
function renderPins() {
$('hero-pins').innerHTML = pins.length
? '<div class="chips">' + pins.map((item, index) =>
'<button class="quiet small" data-remove="' + fmt.escape(item.id) + '">' +
(index + 1) + '. ' + fmt.escape(item.name) + (item.year ? ' (' + item.year + ')' : '') +
' · remove</button>').join('') + '</div>'
: ui.empty('No titles are pinned. The hero is entirely release-aware and automatic.');
for (const button of $('hero-pins').querySelectorAll('[data-remove]')) {
button.addEventListener('click', () => {
pins = pins.filter((item) => item.id !== button.dataset.remove);
pinsDirty = true;
renderPins();
});
}
}
Admin.onStatus((status) => {
if (pinsDirty || !Admin.settled($('hero-pins'))) return;
pins = (status.heroPolicy?.pinnedItems || []).slice(0, 4);
Admin.fill($('hero-prime-subtitle'), status.heroPolicy?.primeSubtitle || '');
renderPins();
});
async function search() {
const query = $('hero-query').value.trim();
if (!query) return;
const payload = await Admin.api('/admin/api/hero/search?q=' + encodeURIComponent(query));
const items = payload.items || [];
$('hero-results').innerHTML = items.length ? items.map((item) =>
'<section class="card"><h2 class="card-title">' + fmt.escape(item.name) + '</h2>' +
'<p class="card-note">' + fmt.escape(item.type || 'Title') + ' · ' +
(item.year || 'Year unknown') + '</p>' +
'<button data-add="' + fmt.escape(item.id) + '">Add to hero</button></section>').join('')
: ui.empty('No playable films or series matched that search.');
for (const button of $('hero-results').querySelectorAll('[data-add]')) {
button.addEventListener('click', () => {
const selected = items.find((item) => item.id === button.dataset.add);
if (!selected || pins.some((item) => item.id === selected.id)) return;
if (pins.length >= 4) {
Admin.error('Remove a pinned film before adding another.');
return;
}
pins.push(selected);
pinsDirty = true;
renderPins();
Admin.error('');
});
}
}
Admin.ready(() => {
$('hero-search').addEventListener('click', () => Admin.act(search));
$('hero-query').addEventListener('keydown', (event) => {
if (event.key === 'Enter') Admin.act(search);
});
$('hero-save').addEventListener('click', () => Admin.act(async () => {
await Admin.api('/admin/api/hero-policy', {
method: 'POST', body: JSON.stringify({
pinnedItemIds: pins.map((item) => item.id),
primeSubtitle: $('hero-prime-subtitle').value.trim(),
}),
});
pinsDirty = false;
}));
$('hero-clear').addEventListener('click', () => {
pins = [];
pinsDirty = true;
renderPins();
});
$('hero-prime-subtitle').addEventListener('input', () => { pinsDirty = true; });
});
+5
View File
@@ -112,6 +112,11 @@ var adminNav = []adminNavGroup{
{
Label: "Experience",
Items: []adminNavItem{
{
ID: "hero", Label: "Home hero", Title: "Home hero",
Intro: "Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.",
Icon: "m12 3 2.1 5.4 5.9.4-4.6 3.8 1.5 5.7-4.9-3.2-4.9 3.2 1.5-5.7L4 8.8l5.9-.4L12 3Z",
},
{
ID: "features", Label: "Features", Title: "Features",
Intro: "Roll out, stop and recover optional behaviour with no app release.",
+100 -3
View File
@@ -57,6 +57,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"
)
const (
@@ -134,6 +135,7 @@ type heroKind int
const (
heroMovie heroKind = iota
heroSeries
heroSeriesPremiere
heroSeasonPremiere
)
@@ -345,6 +347,8 @@ func heroReason(candidate heroCandidate, now time.Time, location *time.Location)
return "A well-reviewed new series, " + heroWhen(candidate.ReleasedAt, now, location)
case candidate.Kind == heroSeriesPremiere:
return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location)
case candidate.Kind == heroSeries && fresh:
return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location)
case candidate.Kind == heroSeasonPremiere:
return "A new season started " + heroWhen(candidate.ReleasedAt, now, location)
case fresh && acclaimed && candidate.Estimated:
@@ -363,6 +367,19 @@ func heroReason(candidate heroCandidate, now time.Time, location *time.Location)
}
}
func heroReasonForPosition(
candidate heroCandidate,
position int,
primeSubtitle string,
now time.Time,
location *time.Location,
) string {
if position == 0 && strings.TrimSpace(primeSubtitle) != "" {
return strings.TrimSpace(primeSubtitle)
}
return heroReason(candidate, now, location)
}
// heroWhen words a date the way somebody would say it out loud. Nothing here is more
// precise than the evidence: a digital release date carries no time of day, so a card
// never claims an hour.
@@ -648,23 +665,30 @@ func (s *Server) heroRow(
location = time.Local
}
candidates := s.heroCandidates(ctx, rows, now)
policy, err := s.store.HeroPolicy(ctx)
if err != nil {
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
policy = store.HeroPolicy{}
}
pinned := s.pinnedHeroCandidates(ctx, policy.PinnedItemIDs)
// 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.
pool := rankHeroCandidates(candidates, now, heroPoolLimit)
ranked := rotateHeroCandidates(
organic := rotateHeroCandidates(
pool,
heroVariationSeed(userID, heroRotationSlot(now, location)),
heroRowLimit,
)
ranked := mergePinnedHeroCandidates(pinned, candidates, organic, heroRowLimit)
if len(ranked) == 0 {
return nil
}
items := make([]json.RawMessage, 0, len(ranked))
for _, candidate := range ranked {
for index, candidate := range ranked {
items = append(items, injectHeroFields(
candidate.Item,
heroLabel(candidate, now),
heroReason(candidate, now, location),
heroReasonForPosition(candidate, index, policy.PrimeSubtitle, now, location),
))
}
return &recommend.Row{
@@ -675,6 +699,79 @@ func (s *Server) heroRow(
}
}
// mergePinnedHeroCandidates places explicit operator choices in the visible grid first,
// then fills it with the normal release-aware rotation. When a pin also qualified
// organically, its release or premiere evidence is retained so pinning changes placement,
// never presentation.
func mergePinnedHeroCandidates(
pinned, evidence, organic []heroCandidate,
limit int,
) []heroCandidate {
if limit <= 0 {
return nil
}
out := make([]heroCandidate, 0, limit)
seen := map[string]bool{}
evidenceByID := make(map[string]heroCandidate, len(evidence))
for _, candidate := range evidence {
evidenceByID[candidate.ID] = candidate
}
for index, candidate := range pinned {
if natural, ok := evidenceByID[candidate.ID]; ok {
pinned[index] = natural
}
}
appendUnique := func(candidates []heroCandidate) {
for _, candidate := range candidates {
if len(out) == limit {
return
}
if candidate.ID == "" || len(candidate.Item) == 0 || seen[candidate.ID] {
continue
}
seen[candidate.ID] = true
out = append(out, candidate)
}
}
appendUnique(pinned)
appendUnique(organic)
return out
}
// pinnedHeroCandidates resolves policy against the imported catalogue. A deleted or
// unsupported id quietly drops out, so an old admin choice can never make Home fail.
func (s *Server) pinnedHeroCandidates(ctx context.Context, ids []string) []heroCandidate {
items, err := s.store.LibraryItemsByID(ctx, ids)
if err != nil {
s.loggerFor(ctx).Warn("pinned hero titles unavailable", "error", err)
return nil
}
byID := make(map[string]heroCandidate, len(items))
for _, raw := range items {
fact, ok := heroFactsOf(raw)
if !ok || !fact.Playable ||
(!strings.EqualFold(fact.Type, "Movie") && !strings.EqualFold(fact.Type, "Series")) {
continue
}
rating, rated := heroRatingOf(raw)
kind := heroMovie
if strings.EqualFold(fact.Type, "Series") {
kind = heroSeries
}
byID[fact.ID] = heroCandidate{
ID: fact.ID, Name: fact.Name, Kind: kind, Item: raw,
ReleasedAt: fact.Premiere, Rating: rating, Rated: rated,
}
}
out := make([]heroCandidate, 0, len(ids))
for _, id := range ids {
if candidate, ok := byID[id]; ok {
out = append(out, candidate)
}
}
return out
}
// heroCandidates gathers everything eligible, premieres first.
//
// Premieres lead the input order so that they win a tie against a film of identical
+145
View File
@@ -0,0 +1,145 @@
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 {
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{}}
}
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,
}
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 {
PinnedItemIDs []string `json:"pinnedItemIds"`
PrimeSubtitle string `json:"primeSubtitle"`
}
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
}
}
policy := store.HeroPolicy{PinnedItemIDs: ids, PrimeSubtitle: request.PrimeSubtitle}
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)
}
}
}
+72
View File
@@ -537,6 +537,78 @@ func TestHeroRotationSlotIsLocal(t *testing.T) {
}
}
func TestPinnedHeroMoviesLeadAndOrganicSelectionFillsTheGrid(t *testing.T) {
pinned := []heroCandidate{
{ID: "custom-2", Item: json.RawMessage(`{"Id":"custom-2"}`)},
{ID: "custom-1", Item: json.RawMessage(`{"Id":"custom-1"}`)},
}
organic := []heroCandidate{
// A pinned title can also qualify organically; it must still appear only once.
{ID: "custom-1", Item: json.RawMessage(`{"Id":"custom-1"}`), Kind: heroSeasonPremiere},
{ID: "release-1", Item: json.RawMessage(`{"Id":"release-1"}`)},
{ID: "release-2", Item: json.RawMessage(`{"Id":"release-2"}`)},
{ID: "library-1", Item: json.RawMessage(`{"Id":"library-1"}`)},
}
got := mergePinnedHeroCandidates(pinned, organic, organic[1:], 4)
want := []string{"custom-2", "custom-1", "release-1", "release-2"}
if strings.Join(heroIDs(got), ",") != strings.Join(want, ",") {
t.Fatalf("pinned hero order = %v, want %v", heroIDs(got), want)
}
if got[1].Kind != heroSeasonPremiere {
t.Fatalf("pin lost its natural premiere evidence: %+v", got[1])
}
}
func TestPinnedHeroMergeHonoursAnEmptyOrShortGrid(t *testing.T) {
candidate := heroCandidate{ID: "custom", Item: json.RawMessage(`{"Id":"custom"}`)}
if got := mergePinnedHeroCandidates([]heroCandidate{candidate}, nil, nil, 0); got != nil {
t.Fatalf("zero-sized grid = %v, want nil", heroIDs(got))
}
if got := heroIDs(mergePinnedHeroCandidates([]heroCandidate{candidate}, nil, nil, 4)); strings.Join(got, ",") != "custom" {
t.Fatalf("short grid = %v, want custom", got)
}
}
func TestPrimeSubtitleChangesOnlyTheLargeFirstCard(t *testing.T) {
candidate := heroCandidate{ReleasedAt: heroDaysAgo(2), Kind: heroMovie}
if got := heroReasonForPosition(candidate, 0, " Family pick tonight ", heroNow, time.UTC); got != "Family pick tonight" {
t.Fatalf("prime subtitle = %q", got)
}
if got := heroReasonForPosition(candidate, 1, "Family pick tonight", heroNow, time.UTC); got == "Family pick tonight" || !strings.Contains(got, "Released") {
t.Fatalf("secondary card lost its natural reason: %q", got)
}
if got := heroReasonForPosition(candidate, 0, "", heroNow, time.UTC); !strings.Contains(got, "Released") {
t.Fatalf("blank override did not fall back to the natural reason: %q", got)
}
}
func TestPinnedSeriesUsesTheSameNaturalPresentationAsOrganicTitles(t *testing.T) {
candidate := heroCandidate{Kind: heroSeries, ReleasedAt: heroDaysAgo(3)}
if got := heroLabel(candidate, heroNow); got != heroLabelNewRelease {
t.Fatalf("recent series label = %q, want %q", got, heroLabelNewRelease)
}
if got := heroReason(candidate, heroNow, time.UTC); !strings.Contains(got, "new series premiered") {
t.Fatalf("recent series reason = %q", got)
}
candidate.ReleasedAt = heroDaysAgo(100)
candidate.Rated, candidate.Rating = true, 0.9
if got := heroLabel(candidate, heroNow); got != heroLabelAcclaimed {
t.Fatalf("older acclaimed series label = %q, want %q", got, heroLabelAcclaimed)
}
}
func TestAdminHeroSearchAcceptsFilmsAndSeriesOnly(t *testing.T) {
for _, itemType := range []string{"Movie", "Series"} {
if item, ok := adminHeroItem(heroItem("id", "Title", itemType)); !ok || item.Type != itemType {
t.Fatalf("%s was not accepted: %+v, %t", itemType, item, ok)
}
}
if _, ok := adminHeroItem(heroItem("episode", "Episode", "Episode")); ok {
t.Fatal("episode was accepted as a pinnable hero title")
}
}
func heroIDs(candidates []heroCandidate) []string {
ids := make([]string, 0, len(candidates))
for _, candidate := range candidates {
+131
View File
@@ -0,0 +1,131 @@
package api
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
// WatchSonarrLifecycle seeds the durable status history on startup, then refreshes it
// daily. History stores changes rather than identical daily snapshots: it still records
// the complete lifecycle while making an active-to-cancelled transition unambiguous.
func (s *Server) WatchSonarrLifecycle(ctx context.Context, interval time.Duration) {
if s.sonarr == nil || interval <= 0 {
return
}
scan := func() {
if err := s.scanSonarrLifecycle(ctx); err != nil && ctx.Err() == nil {
s.log.Warn("Sonarr lifecycle scan failed", "error", err)
}
}
scan()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
scan()
}
}
}
func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
series, err := s.sonarr.Series(ctx)
if err != nil {
return fmt.Errorf("read Sonarr series: %w", err)
}
now := time.Now().UTC()
observations := make([]store.SonarrSeriesStatus, 0, len(series))
for _, item := range series {
key := sonarrSeriesStatusKey(item)
if key == "" || strings.TrimSpace(item.Status) == "" {
continue
}
observations = append(observations, store.SonarrSeriesStatus{
SeriesKey: key, SonarrSeriesID: item.ID, TVDBID: item.TVDBID,
Title: item.Title, Year: item.Year, Status: item.Status, ObservedAt: now,
})
}
changes, err := s.store.RecordSonarrSeriesStatuses(ctx, observations)
if err != nil {
return err
}
cancellations := make([]store.SonarrSeriesStatusChange, 0, len(changes))
for _, change := range changes {
if sonarrBecameCancelled(change.PreviousStatus, change.Current.Status) {
cancellations = append(cancellations, change)
}
}
if len(cancellations) == 0 {
s.log.Info("Sonarr lifecycle scan complete", "series", len(observations), "changes", len(changes))
return nil
}
users, err := s.store.KnownUsers(ctx)
if err != nil {
return err
}
preferences := map[string]store.NotificationPreferences{}
preferenceErrors := map[string]bool{}
notifications := 0
for _, change := range cancellations {
for _, user := range users {
prefs, ok := preferences[user.ID]
if !ok && !preferenceErrors[user.ID] {
prefs, err = s.store.NotificationPreferences(ctx, user.ID)
if err != nil {
preferenceErrors[user.ID] = true
s.log.Warn("notification preferences unavailable during Sonarr lifecycle scan",
"user", user.ID, "error", err)
continue
}
preferences[user.ID] = prefs
}
if preferenceErrors[user.ID] || !prefs.Enabled {
continue
}
eventAt := change.Current.ObservedAt
sourceKey := fmt.Sprintf("show-cancelled:%s:%d", change.Current.SeriesKey, change.HistoryID)
message := change.Current.Title + " is now listed as cancelled in Sonarr."
if err := s.store.UpsertNotification(
ctx, user.ID, sourceKey, "show-cancelled", "",
"Show cancelled", message, &eventAt,
); err != nil {
s.log.Warn("Sonarr cancellation notification failed",
"user", user.ID, "show", change.Current.Title, "error", err)
continue
}
notifications++
}
}
s.log.Info("Sonarr lifecycle scan complete",
"series", len(observations), "changes", len(changes),
"cancelled", len(cancellations), "notifications", notifications)
return nil
}
func sonarrSeriesStatusKey(series sonarr.Series) string {
if series.TVDBID > 0 {
return "tvdb:" + strconv.Itoa(series.TVDBID)
}
if series.ID > 0 {
return "sonarr:" + strconv.Itoa(series.ID)
}
return ""
}
func sonarrBecameCancelled(previous, current string) bool {
previous = strings.ToLower(strings.TrimSpace(previous))
current = strings.ToLower(strings.TrimSpace(current))
active := previous == "continuing" || previous == "upcoming"
cancelled := current == "ended" || current == "deleted" ||
current == "cancelled" || current == "canceled"
return active && cancelled
}
+40
View File
@@ -0,0 +1,40 @@
package api
import (
"testing"
"github.com/ponzischeme89/memby/server/internal/sonarr"
)
func TestSonarrSeriesStatusKeyPrefersTVDBIdentity(t *testing.T) {
series := sonarr.Series{ID: 42, TVDBID: 1234}
if got := sonarrSeriesStatusKey(series); got != "tvdb:1234" {
t.Fatalf("status key = %q, want tvdb:1234", got)
}
series.TVDBID = 0
if got := sonarrSeriesStatusKey(series); got != "sonarr:42" {
t.Fatalf("fallback status key = %q, want sonarr:42", got)
}
}
func TestSonarrCancellationRequiresAnActiveHistory(t *testing.T) {
for _, transition := range [][2]string{
{"continuing", "ended"},
{"upcoming", "deleted"},
{"Continuing", "cancelled"},
} {
if !sonarrBecameCancelled(transition[0], transition[1]) {
t.Errorf("%q -> %q should be a cancellation", transition[0], transition[1])
}
}
for _, transition := range [][2]string{
{"", "ended"}, // first scan is a baseline, not news
{"ended", "ended"}, // an unchanged cancelled show is not announced daily
{"ended", "continuing"},
{"continuing", "continuing"},
} {
if sonarrBecameCancelled(transition[0], transition[1]) {
t.Errorf("%q -> %q should not be a cancellation", transition[0], transition[1])
}
}
}
+96
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"fmt"
"strings"
"time"
)
@@ -31,6 +32,24 @@ type UserNotification struct {
ReadAt *time.Time `json:"readAt,omitempty"`
}
// SonarrSeriesStatus is one daily observation. SeriesKey prefers Sonarr's stable TVDB id;
// the local Sonarr id is retained for diagnosis and as a fallback when TVDB has no answer.
type SonarrSeriesStatus struct {
SeriesKey string
SonarrSeriesID int
TVDBID int
Title string
Year int
Status string
ObservedAt time.Time
}
type SonarrSeriesStatusChange struct {
HistoryID int64
PreviousStatus string
Current SonarrSeriesStatus
}
func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
@@ -89,6 +108,83 @@ func (s *Store) UserShows(ctx context.Context, userID string) ([]UserShow, error
return shows, rows.Err()
}
// RecordSonarrSeriesStatuses appends only first sightings and changes. A first sighting is
// the baseline and is deliberately absent from the returned changes, so enabling the
// scanner cannot announce every series that was already cancelled before it existed.
func (s *Store) RecordSonarrSeriesStatuses(
ctx context.Context, observations []SonarrSeriesStatus,
) ([]SonarrSeriesStatusChange, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("store: begin Sonarr status history: %w", err)
}
defer tx.Rollback(ctx)
rows, err := tx.Query(ctx, `
SELECT DISTINCT ON (series_key) series_key, status
FROM sonarr_series_status_history
ORDER BY series_key, observed_at DESC, id DESC`)
if err != nil {
return nil, fmt.Errorf("store: read Sonarr status history: %w", err)
}
previous := map[string]string{}
for rows.Next() {
var key, status string
if err := rows.Scan(&key, &status); err != nil {
rows.Close()
return nil, fmt.Errorf("store: scan Sonarr status history: %w", err)
}
previous[key] = status
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("store: iterate Sonarr status history: %w", err)
}
rows.Close()
changes := []SonarrSeriesStatusChange{}
seen := map[string]bool{}
for _, observation := range observations {
observation.SeriesKey = strings.TrimSpace(observation.SeriesKey)
observation.Title = strings.TrimSpace(observation.Title)
observation.Status = strings.ToLower(strings.TrimSpace(observation.Status))
if observation.SeriesKey == "" || observation.Title == "" || observation.Status == "" ||
seen[observation.SeriesKey] {
continue
}
seen[observation.SeriesKey] = true
prior, known := previous[observation.SeriesKey]
if known && strings.EqualFold(prior, observation.Status) {
continue
}
if observation.ObservedAt.IsZero() {
observation.ObservedAt = time.Now()
}
var historyID int64
err := tx.QueryRow(ctx, `
INSERT INTO sonarr_series_status_history
(series_key, sonarr_series_id, tvdb_id, title, year, status, observed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
observation.SeriesKey, observation.SonarrSeriesID, observation.TVDBID,
observation.Title, observation.Year, observation.Status, observation.ObservedAt,
).Scan(&historyID)
if err != nil {
return nil, fmt.Errorf("store: append Sonarr status history: %w", err)
}
if known {
changes = append(changes, SonarrSeriesStatusChange{
HistoryID: historyID, PreviousStatus: prior, Current: observation,
})
}
previous[observation.SeriesKey] = observation.Status
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("store: commit Sonarr status history: %w", err)
}
return changes, nil
}
func (s *Store) NotificationPreferences(ctx context.Context, userID string) (NotificationPreferences, error) {
prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7}
err := s.pool.QueryRow(ctx, `
+17
View File
@@ -239,6 +239,23 @@ CREATE TABLE IF NOT EXISTS user_notifications (
CREATE INDEX IF NOT EXISTS user_notifications_user_created_idx
ON user_notifications (emby_user_id, created_at DESC);
-- A change-only history of Sonarr's lifecycle answer for every series. The first reading
-- is a baseline; later rows mean Sonarr changed its answer, which lets the daily scanner
-- distinguish a show that was already over from one that has just been cancelled.
CREATE TABLE IF NOT EXISTS sonarr_series_status_history (
id BIGSERIAL PRIMARY KEY,
series_key TEXT NOT NULL,
sonarr_series_id INT NOT NULL DEFAULT 0,
tvdb_id INT NOT NULL DEFAULT 0,
title TEXT NOT NULL,
year INT NOT NULL DEFAULT 0,
status TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS sonarr_series_status_history_series_time_idx
ON sonarr_series_status_history (series_key, observed_at DESC, id DESC);
-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or
-- since cursor, so stable source ids make these rows the durable deduplication boundary.
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
+71 -1
View File
@@ -18,14 +18,84 @@ const MaintenanceKey = "maintenance"
// RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr.
const RequestPolicyKey = "request_policy"
// PlaybackPolicyKey controls presentation behavior that should be adjustable without
// PlaybackPolicyKey controls presentation behaviour that should be adjustable without
// shipping a new TV build.
const PlaybackPolicyKey = "playback_policy"
// HeroPolicyKey stores the operator's explicit choices for the launcher hero.
const HeroPolicyKey = "hero_policy"
// MDBListSettingsKey stores the optional movie-ratings integration. The API key stays
// in this server-owned document and is never included in client or admin status payloads.
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"`
}
func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
if len(policy.PinnedItemIDs) == 0 && len(policy.LegacyPinnedMovieIDs) > 0 {
policy.PinnedItemIDs = policy.LegacyPinnedMovieIDs
}
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.LegacyPinnedMovieIDs = nil
policy.PrimeSubtitle = strings.TrimSpace(policy.PrimeSubtitle)
runes := []rune(policy.PrimeSubtitle)
if len(runes) > 160 {
policy.PrimeSubtitle = string(runes[:160])
}
return policy
}
func (s *Store) HeroPolicy(ctx context.Context) (HeroPolicy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, HeroPolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return HeroPolicy{PinnedItemIDs: []string{}}, nil
}
if err != nil {
return HeroPolicy{}, fmt.Errorf("store: read hero policy: %w", err)
}
var policy HeroPolicy
if err := json.Unmarshal(raw, &policy); err != nil {
return HeroPolicy{}, fmt.Errorf("store: decode hero policy: %w", err)
}
return normalizeHeroPolicy(policy), nil
}
func (s *Store) SetHeroPolicy(ctx context.Context, policy HeroPolicy) error {
policy = normalizeHeroPolicy(policy)
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
HeroPolicyKey, string(raw))
if err != nil {
return fmt.Errorf("store: write hero policy: %w", err)
}
return nil
}
var defaultMDBListSources = []string{
"imdb", "tomatoes", "audience", "metacritic", "letterboxd", "rogerebert",
"tmdb", "trakt", "mal", "anilist", "anidb", "kitsu", "score", "score_average",
+26 -1
View File
@@ -25,6 +25,31 @@ func TestPlaybackPolicyDefaultsAndClampsDuration(t *testing.T) {
}
}
func TestHeroPolicyKeepsFourUniqueTrimmedItemIDsInOrder(t *testing.T) {
got := normalizeHeroPolicy(HeroPolicy{PinnedItemIDs: []string{
" movie-2 ", "movie-1", "movie-2", "", "movie-3", "movie-4", "movie-5",
}, PrimeSubtitle: " Tonights pick "})
want := []string{"movie-2", "movie-1", "movie-3", "movie-4"}
if len(got.PinnedItemIDs) != len(want) {
t.Fatalf("pinned ids = %v, want %v", got.PinnedItemIDs, want)
}
for index := range want {
if got.PinnedItemIDs[index] != want[index] {
t.Fatalf("pinned ids = %v, want %v", got.PinnedItemIDs, want)
}
}
if got.PrimeSubtitle != "Tonights pick" {
t.Fatalf("prime subtitle = %q", got.PrimeSubtitle)
}
}
func TestHeroPolicyReadsTheEarlierMovieOnlyShape(t *testing.T) {
got := normalizeHeroPolicy(HeroPolicy{LegacyPinnedMovieIDs: []string{"movie-1"}})
if len(got.PinnedItemIDs) != 1 || got.PinnedItemIDs[0] != "movie-1" {
t.Fatalf("legacy pinned ids = %v", got.PinnedItemIDs)
}
}
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
defaults := DefaultMDBListSettings()
if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 {
@@ -35,7 +60,7 @@ func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
})
if got.APIKey != "secret" || len(got.Sources) != 2 ||
got.Sources[0] != "imdb" || got.Sources[1] != "letterboxd" {
t.Fatalf("normalized MDBList settings = %+v", got)
t.Fatalf("normalised MDBList settings = %+v", got)
}
}