0.2.52
This commit is contained in:
@@ -190,6 +190,9 @@ func run(log *slog.Logger, events *logging.Buffer) error {
|
||||
go server.WatchMaintenance(ctx, 30*time.Second)
|
||||
// One probe per gateway, not per TV: the answer is the same for the whole house.
|
||||
go server.WatchEmbyReachability(ctx, cfg.EmbyHealthInterval)
|
||||
// One Sonarr catalogue reading per day records lifecycle changes for the household and
|
||||
// materialises cancellation notifications for every known viewer.
|
||||
go server.WatchSonarrLifecycle(ctx, 24*time.Hour)
|
||||
|
||||
if err := server.LoadUpdatePolicy(ctx); err != nil {
|
||||
return err
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, `
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: " Tonight’s 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 != "Tonight’s 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user