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])
}
}
}