0.2.52
This commit is contained in:
@@ -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