0.2.52
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 Memby’s 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 Memby’s 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>
|
||||
@@ -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; });
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user