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
+96
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"fmt"
"strings"
"time"
)
@@ -31,6 +32,24 @@ type UserNotification struct {
ReadAt *time.Time `json:"readAt,omitempty"`
}
// SonarrSeriesStatus is one daily observation. SeriesKey prefers Sonarr's stable TVDB id;
// the local Sonarr id is retained for diagnosis and as a fallback when TVDB has no answer.
type SonarrSeriesStatus struct {
SeriesKey string
SonarrSeriesID int
TVDBID int
Title string
Year int
Status string
ObservedAt time.Time
}
type SonarrSeriesStatusChange struct {
HistoryID int64
PreviousStatus string
Current SonarrSeriesStatus
}
func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
@@ -89,6 +108,83 @@ func (s *Store) UserShows(ctx context.Context, userID string) ([]UserShow, error
return shows, rows.Err()
}
// RecordSonarrSeriesStatuses appends only first sightings and changes. A first sighting is
// the baseline and is deliberately absent from the returned changes, so enabling the
// scanner cannot announce every series that was already cancelled before it existed.
func (s *Store) RecordSonarrSeriesStatuses(
ctx context.Context, observations []SonarrSeriesStatus,
) ([]SonarrSeriesStatusChange, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("store: begin Sonarr status history: %w", err)
}
defer tx.Rollback(ctx)
rows, err := tx.Query(ctx, `
SELECT DISTINCT ON (series_key) series_key, status
FROM sonarr_series_status_history
ORDER BY series_key, observed_at DESC, id DESC`)
if err != nil {
return nil, fmt.Errorf("store: read Sonarr status history: %w", err)
}
previous := map[string]string{}
for rows.Next() {
var key, status string
if err := rows.Scan(&key, &status); err != nil {
rows.Close()
return nil, fmt.Errorf("store: scan Sonarr status history: %w", err)
}
previous[key] = status
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("store: iterate Sonarr status history: %w", err)
}
rows.Close()
changes := []SonarrSeriesStatusChange{}
seen := map[string]bool{}
for _, observation := range observations {
observation.SeriesKey = strings.TrimSpace(observation.SeriesKey)
observation.Title = strings.TrimSpace(observation.Title)
observation.Status = strings.ToLower(strings.TrimSpace(observation.Status))
if observation.SeriesKey == "" || observation.Title == "" || observation.Status == "" ||
seen[observation.SeriesKey] {
continue
}
seen[observation.SeriesKey] = true
prior, known := previous[observation.SeriesKey]
if known && strings.EqualFold(prior, observation.Status) {
continue
}
if observation.ObservedAt.IsZero() {
observation.ObservedAt = time.Now()
}
var historyID int64
err := tx.QueryRow(ctx, `
INSERT INTO sonarr_series_status_history
(series_key, sonarr_series_id, tvdb_id, title, year, status, observed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
observation.SeriesKey, observation.SonarrSeriesID, observation.TVDBID,
observation.Title, observation.Year, observation.Status, observation.ObservedAt,
).Scan(&historyID)
if err != nil {
return nil, fmt.Errorf("store: append Sonarr status history: %w", err)
}
if known {
changes = append(changes, SonarrSeriesStatusChange{
HistoryID: historyID, PreviousStatus: prior, Current: observation,
})
}
previous[observation.SeriesKey] = observation.Status
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("store: commit Sonarr status history: %w", err)
}
return changes, nil
}
func (s *Store) NotificationPreferences(ctx context.Context, userID string) (NotificationPreferences, error) {
prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7}
err := s.pool.QueryRow(ctx, `
+17
View File
@@ -239,6 +239,23 @@ CREATE TABLE IF NOT EXISTS user_notifications (
CREATE INDEX IF NOT EXISTS user_notifications_user_created_idx
ON user_notifications (emby_user_id, created_at DESC);
-- A change-only history of Sonarr's lifecycle answer for every series. The first reading
-- is a baseline; later rows mean Sonarr changed its answer, which lets the daily scanner
-- distinguish a show that was already over from one that has just been cancelled.
CREATE TABLE IF NOT EXISTS sonarr_series_status_history (
id BIGSERIAL PRIMARY KEY,
series_key TEXT NOT NULL,
sonarr_series_id INT NOT NULL DEFAULT 0,
tvdb_id INT NOT NULL DEFAULT 0,
title TEXT NOT NULL,
year INT NOT NULL DEFAULT 0,
status TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS sonarr_series_status_history_series_time_idx
ON sonarr_series_status_history (series_key, observed_at DESC, id DESC);
-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or
-- since cursor, so stable source ids make these rows the durable deduplication boundary.
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
+71 -1
View File
@@ -18,14 +18,84 @@ const MaintenanceKey = "maintenance"
// RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr.
const RequestPolicyKey = "request_policy"
// PlaybackPolicyKey controls presentation behavior that should be adjustable without
// PlaybackPolicyKey controls presentation behaviour that should be adjustable without
// shipping a new TV build.
const PlaybackPolicyKey = "playback_policy"
// HeroPolicyKey stores the operator's explicit choices for the launcher hero.
const HeroPolicyKey = "hero_policy"
// MDBListSettingsKey stores the optional movie-ratings integration. The API key stays
// in this server-owned document and is never included in client or admin status payloads.
const MDBListSettingsKey = "mdblist_settings"
// HeroPolicy stores only Emby ids and the optional prime-card copy. Names and artwork
// remain library data, so a metadata correction appears without rewriting operator policy.
type HeroPolicy struct {
PinnedItemIDs []string `json:"pinnedItemIds"`
LegacyPinnedMovieIDs []string `json:"pinnedMovieIds,omitempty"`
PrimeSubtitle string `json:"primeSubtitle"`
UpdatedAt time.Time `json:"updatedAt"`
}
func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
if len(policy.PinnedItemIDs) == 0 && len(policy.LegacyPinnedMovieIDs) > 0 {
policy.PinnedItemIDs = policy.LegacyPinnedMovieIDs
}
seen := map[string]bool{}
ids := make([]string, 0, min(len(policy.PinnedItemIDs), 4))
for _, id := range policy.PinnedItemIDs {
id = strings.TrimSpace(id)
if id == "" || seen[id] || len(ids) == 4 {
continue
}
seen[id] = true
ids = append(ids, id)
}
policy.PinnedItemIDs = ids
policy.LegacyPinnedMovieIDs = nil
policy.PrimeSubtitle = strings.TrimSpace(policy.PrimeSubtitle)
runes := []rune(policy.PrimeSubtitle)
if len(runes) > 160 {
policy.PrimeSubtitle = string(runes[:160])
}
return policy
}
func (s *Store) HeroPolicy(ctx context.Context) (HeroPolicy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, HeroPolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return HeroPolicy{PinnedItemIDs: []string{}}, nil
}
if err != nil {
return HeroPolicy{}, fmt.Errorf("store: read hero policy: %w", err)
}
var policy HeroPolicy
if err := json.Unmarshal(raw, &policy); err != nil {
return HeroPolicy{}, fmt.Errorf("store: decode hero policy: %w", err)
}
return normalizeHeroPolicy(policy), nil
}
func (s *Store) SetHeroPolicy(ctx context.Context, policy HeroPolicy) error {
policy = normalizeHeroPolicy(policy)
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
HeroPolicyKey, string(raw))
if err != nil {
return fmt.Errorf("store: write hero policy: %w", err)
}
return nil
}
var defaultMDBListSources = []string{
"imdb", "tomatoes", "audience", "metacritic", "letterboxd", "rogerebert",
"tmdb", "trakt", "mal", "anilist", "anidb", "kitsu", "score", "score_average",
+26 -1
View File
@@ -25,6 +25,31 @@ func TestPlaybackPolicyDefaultsAndClampsDuration(t *testing.T) {
}
}
func TestHeroPolicyKeepsFourUniqueTrimmedItemIDsInOrder(t *testing.T) {
got := normalizeHeroPolicy(HeroPolicy{PinnedItemIDs: []string{
" movie-2 ", "movie-1", "movie-2", "", "movie-3", "movie-4", "movie-5",
}, PrimeSubtitle: " Tonights pick "})
want := []string{"movie-2", "movie-1", "movie-3", "movie-4"}
if len(got.PinnedItemIDs) != len(want) {
t.Fatalf("pinned ids = %v, want %v", got.PinnedItemIDs, want)
}
for index := range want {
if got.PinnedItemIDs[index] != want[index] {
t.Fatalf("pinned ids = %v, want %v", got.PinnedItemIDs, want)
}
}
if got.PrimeSubtitle != "Tonights pick" {
t.Fatalf("prime subtitle = %q", got.PrimeSubtitle)
}
}
func TestHeroPolicyReadsTheEarlierMovieOnlyShape(t *testing.T) {
got := normalizeHeroPolicy(HeroPolicy{LegacyPinnedMovieIDs: []string{"movie-1"}})
if len(got.PinnedItemIDs) != 1 || got.PinnedItemIDs[0] != "movie-1" {
t.Fatalf("legacy pinned ids = %v", got.PinnedItemIDs)
}
}
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
defaults := DefaultMDBListSettings()
if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 {
@@ -35,7 +60,7 @@ func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
})
if got.APIKey != "secret" || len(got.Sources) != 2 ||
got.Sources[0] != "imdb" || got.Sources[1] != "letterboxd" {
t.Fatalf("normalized MDBList settings = %+v", got)
t.Fatalf("normalised MDBList settings = %+v", got)
}
}