0.2.64 update
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// The database half of credits marking.
|
||||
//
|
||||
// Four queries, and the shape of each one is chosen to keep the promise the subsystem makes
|
||||
// about database activity: a settled household reads one indexed row per candidate and
|
||||
// writes nothing at all. Nothing here is written per candidate, per queue transition or per
|
||||
// scan attempt — only a finished marker.
|
||||
|
||||
// CreditsMarkerRow is one stored marker.
|
||||
type CreditsMarkerRow struct {
|
||||
ItemID string
|
||||
MediaFingerprint string
|
||||
CreditsStartMs int64
|
||||
Confidence float64
|
||||
DetectionMethod string
|
||||
SeriesID string
|
||||
Season int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CreditsMarker reads the marker for one media version. Absence is an ordinary answer.
|
||||
func (s *Store) CreditsMarker(
|
||||
ctx context.Context, itemID, fingerprint string,
|
||||
) (CreditsMarkerRow, bool, error) {
|
||||
var row CreditsMarkerRow
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT item_id, media_fingerprint, credits_start_ms, confidence, detection_method,
|
||||
series_id, season_number, created_at, updated_at
|
||||
FROM credits_markers
|
||||
WHERE item_id = $1 AND media_fingerprint = $2`, itemID, fingerprint).
|
||||
Scan(&row.ItemID, &row.MediaFingerprint, &row.CreditsStartMs, &row.Confidence,
|
||||
&row.DetectionMethod, &row.SeriesID, &row.Season, &row.CreatedAt, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return CreditsMarkerRow{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return CreditsMarkerRow{}, false, fmt.Errorf("store: credits marker: %w", err)
|
||||
}
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
// SaveCreditsMarker upserts one marker. This is the single write the whole subsystem makes,
|
||||
// and the caller has already decided that the new evidence is worth it — the stability rule
|
||||
// lives in the credits package beside the confidence model it depends on, not here.
|
||||
//
|
||||
// created_at is preserved on conflict so a marker's age remains the age of the finding rather
|
||||
// than of the last time something confirmed it.
|
||||
func (s *Store) SaveCreditsMarker(ctx context.Context, row CreditsMarkerRow) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO credits_markers (
|
||||
item_id, media_fingerprint, credits_start_ms, confidence, detection_method,
|
||||
series_id, season_number, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
|
||||
ON CONFLICT (item_id, media_fingerprint) DO UPDATE SET
|
||||
credits_start_ms = EXCLUDED.credits_start_ms,
|
||||
confidence = EXCLUDED.confidence,
|
||||
detection_method = EXCLUDED.detection_method,
|
||||
series_id = EXCLUDED.series_id,
|
||||
season_number = EXCLUDED.season_number,
|
||||
updated_at = now()`,
|
||||
row.ItemID, row.MediaFingerprint, row.CreditsStartMs, row.Confidence,
|
||||
row.DetectionMethod, row.SeriesID, row.Season)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: save credits marker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreditsSeasonMarkers reads what is already known about a season, best evidence first.
|
||||
//
|
||||
// This is the single most valuable query in the subsystem. Credits within a season begin at
|
||||
// a consistent point, so two decided episodes turn the next one's ten-minute tail scan into a
|
||||
// three-minute one — which is most of the difference between a feature that is affordable on
|
||||
// a NAS and one that is not.
|
||||
func (s *Store) CreditsSeasonMarkers(
|
||||
ctx context.Context, seriesID string, season, limit int,
|
||||
) ([]CreditsMarkerRow, error) {
|
||||
if seriesID == "" || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT item_id, media_fingerprint, credits_start_ms, confidence, detection_method,
|
||||
series_id, season_number, created_at, updated_at
|
||||
FROM credits_markers
|
||||
WHERE series_id = $1 AND season_number = $2
|
||||
ORDER BY confidence DESC, updated_at DESC
|
||||
LIMIT $3`, seriesID, season, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: credits season markers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CreditsMarkerRow, 0, limit)
|
||||
for rows.Next() {
|
||||
var row CreditsMarkerRow
|
||||
if err := rows.Scan(&row.ItemID, &row.MediaFingerprint, &row.CreditsStartMs,
|
||||
&row.Confidence, &row.DetectionMethod, &row.SeriesID, &row.Season,
|
||||
&row.CreatedAt, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreditsWatchRow is one episode one viewer played, as candidate generation needs it.
|
||||
type CreditsWatchRow struct {
|
||||
UserKey string
|
||||
SeriesID string
|
||||
Season int
|
||||
Episode int
|
||||
WatchedAt time.Time
|
||||
Completed bool
|
||||
}
|
||||
|
||||
// CreditsRecentWatches is the demand signal, and the whole of it: one indexed read of
|
||||
// sessions Tracearr has already imported.
|
||||
//
|
||||
// Nothing is written here and no new ingestion exists — the For You import already maintains
|
||||
// this table and already resolves its rows to Emby ids. That reuse is why demand-driven
|
||||
// candidate generation costs the gateway a single query every ten minutes rather than a
|
||||
// second Tracearr integration.
|
||||
//
|
||||
// Episodes only, and only where the series resolved to something in Emby: a session that
|
||||
// could not be matched cannot produce a scannable candidate, and filtering in SQL keeps the
|
||||
// unmatched majority of an old library out of Go entirely.
|
||||
func (s *Store) CreditsRecentWatches(
|
||||
ctx context.Context, since time.Time, limit int,
|
||||
) ([]CreditsWatchRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
coalesce(nullif(tracearr_user_id, ''), lower(username)) AS user_key,
|
||||
emby_series_id,
|
||||
coalesce(season_number, 0),
|
||||
coalesce(episode_number, 0),
|
||||
coalesce(stopped_at, started_at) AS watched_at,
|
||||
watched OR (total_duration_ms > 0
|
||||
AND progress_ms::float8 / total_duration_ms::float8 >= 0.9) AS completed
|
||||
FROM tracearr_sessions
|
||||
WHERE lower(media_type) = 'episode'
|
||||
AND emby_series_id <> ''
|
||||
AND episode_number IS NOT NULL AND episode_number > 0
|
||||
AND coalesce(stopped_at, started_at) >= $1
|
||||
AND coalesce(nullif(tracearr_user_id, ''), lower(username)) <> ''
|
||||
ORDER BY coalesce(stopped_at, started_at) DESC
|
||||
LIMIT $2`, since.UTC(), limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: credits recent watches: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CreditsWatchRow, 0, 64)
|
||||
for rows.Next() {
|
||||
var row CreditsWatchRow
|
||||
if err := rows.Scan(&row.UserKey, &row.SeriesID, &row.Season,
|
||||
&row.Episode, &row.WatchedAt, &row.Completed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreditsStopRow is one viewer leaving one episode.
|
||||
type CreditsStopRow struct {
|
||||
UserKey string
|
||||
PositionMs int64
|
||||
RuntimeMs int64
|
||||
NextEpisode bool
|
||||
}
|
||||
|
||||
// CreditsStops reads where the household stopped one episode.
|
||||
//
|
||||
// The behavioural detector's entire input, and it needs no new table: Tracearr already
|
||||
// records progress and completion per session. NextEpisode is derived rather than stored —
|
||||
// a session for the following episode of the same series starting within a couple of minutes
|
||||
// of this one ending is an auto-advance, which is the strongest form of this signal because
|
||||
// it says the viewer was unambiguously looking at credits rather than deciding to stop.
|
||||
func (s *Store) CreditsStops(ctx context.Context, itemID string) ([]CreditsStopRow, error) {
|
||||
if itemID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH plays AS (
|
||||
SELECT
|
||||
coalesce(nullif(tracearr_user_id, ''), lower(username)) AS user_key,
|
||||
emby_series_id,
|
||||
season_number,
|
||||
episode_number,
|
||||
progress_ms,
|
||||
total_duration_ms,
|
||||
coalesce(stopped_at, started_at) AS ended_at
|
||||
FROM tracearr_sessions
|
||||
WHERE emby_item_id = $1
|
||||
AND progress_ms > 0
|
||||
AND total_duration_ms > 0
|
||||
AND coalesce(nullif(tracearr_user_id, ''), lower(username)) <> ''
|
||||
)
|
||||
SELECT
|
||||
plays.user_key,
|
||||
plays.progress_ms,
|
||||
plays.total_duration_ms,
|
||||
EXISTS (
|
||||
SELECT 1 FROM tracearr_sessions following
|
||||
WHERE following.emby_series_id = plays.emby_series_id
|
||||
AND coalesce(nullif(following.tracearr_user_id, ''), lower(following.username))
|
||||
= plays.user_key
|
||||
AND following.season_number = plays.season_number
|
||||
AND following.episode_number = plays.episode_number + 1
|
||||
AND following.started_at BETWEEN plays.ended_at - interval '30 seconds'
|
||||
AND plays.ended_at + interval '3 minutes'
|
||||
) AS next_episode
|
||||
FROM plays
|
||||
LIMIT 200`, itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: credits stops: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CreditsStopRow, 0, 16)
|
||||
for rows.Next() {
|
||||
var row CreditsStopRow
|
||||
if err := rows.Scan(&row.UserKey, &row.PositionMs, &row.RuntimeMs,
|
||||
&row.NextEpisode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreditsEpisodeRow is one episode's position in its series.
|
||||
type CreditsEpisodeRow struct {
|
||||
ItemID string
|
||||
SeriesID string
|
||||
Season int
|
||||
Episode int
|
||||
}
|
||||
|
||||
// CreditsSeriesEpisodes reads the numbering of every episode of the given series.
|
||||
//
|
||||
// One query for the handful of series a household is currently watching, rather than a
|
||||
// lookup per candidate. The result becomes an in-memory index, so the look-ahead arithmetic
|
||||
// — including stepping across a season boundary, which is exactly when somebody is most
|
||||
// likely to keep going — is pure and testable with no database at all.
|
||||
func (s *Store) CreditsSeriesEpisodes(
|
||||
ctx context.Context, seriesIDs []string,
|
||||
) ([]CreditsEpisodeRow, error) {
|
||||
if len(seriesIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
id,
|
||||
series_id,
|
||||
coalesce((payload->>'ParentIndexNumber')::int, 0),
|
||||
coalesce((payload->>'IndexNumber')::int, 0)
|
||||
FROM library_items
|
||||
WHERE type = 'Episode'
|
||||
AND series_id = ANY($1)
|
||||
AND payload->>'IndexNumber' IS NOT NULL`, seriesIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: credits series episodes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CreditsEpisodeRow, 0, 128)
|
||||
for rows.Next() {
|
||||
var row CreditsEpisodeRow
|
||||
if err := rows.Scan(&row.ItemID, &row.SeriesID, &row.Season, &row.Episode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.Episode <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -18,9 +18,21 @@ type UserShow struct {
|
||||
type NotificationPreferences struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ShowReturnAlerts bool `json:"showReturnAlerts"`
|
||||
SonarrAlerts bool `json:"sonarrAlerts"`
|
||||
RadarrAlerts bool `json:"radarrAlerts"`
|
||||
UpdateAlerts bool `json:"updateAlerts"`
|
||||
LibraryAlerts bool `json:"libraryAlerts"`
|
||||
SystemAlerts bool `json:"systemAlerts"`
|
||||
LeadDays int `json:"leadDays"`
|
||||
}
|
||||
|
||||
func DefaultNotificationPreferences() NotificationPreferences {
|
||||
return NotificationPreferences{
|
||||
Enabled: true, ShowReturnAlerts: true, SonarrAlerts: true, RadarrAlerts: true,
|
||||
UpdateAlerts: true, LibraryAlerts: true, SystemAlerts: true, LeadDays: 7,
|
||||
}
|
||||
}
|
||||
|
||||
type UserNotification struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
@@ -195,11 +207,13 @@ func (s *Store) RecordSonarrSeriesStatuses(
|
||||
}
|
||||
|
||||
func (s *Store) NotificationPreferences(ctx context.Context, userID string) (NotificationPreferences, error) {
|
||||
prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7}
|
||||
prefs := DefaultNotificationPreferences()
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT enabled, show_return_alerts, lead_days
|
||||
SELECT enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
||||
update_alerts, library_alerts, system_alerts, lead_days
|
||||
FROM user_notification_preferences WHERE emby_user_id = $1`, userID).
|
||||
Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.LeadDays)
|
||||
Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts, &prefs.RadarrAlerts,
|
||||
&prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts, &prefs.LeadDays)
|
||||
if err != nil && !isNoRows(err) {
|
||||
return prefs, fmt.Errorf("store: notification preferences: %w", err)
|
||||
}
|
||||
@@ -217,17 +231,47 @@ func (s *Store) SetNotificationPreferences(
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO user_notification_preferences
|
||||
(emby_user_id, enabled, show_return_alerts, lead_days)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
(emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
||||
update_alerts, library_alerts, system_alerts, lead_days)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (emby_user_id) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
show_return_alerts = EXCLUDED.show_return_alerts,
|
||||
sonarr_alerts = EXCLUDED.sonarr_alerts,
|
||||
radarr_alerts = EXCLUDED.radarr_alerts,
|
||||
update_alerts = EXCLUDED.update_alerts,
|
||||
library_alerts = EXCLUDED.library_alerts,
|
||||
system_alerts = EXCLUDED.system_alerts,
|
||||
lead_days = EXCLUDED.lead_days,
|
||||
updated_at = now()`,
|
||||
userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.LeadDays)
|
||||
userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.SonarrAlerts, prefs.RadarrAlerts,
|
||||
prefs.UpdateAlerts, prefs.LibraryAlerts, prefs.SystemAlerts, prefs.LeadDays)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]NotificationPreferences, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
||||
update_alerts, library_alerts, system_alerts, lead_days
|
||||
FROM user_notification_preferences`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list notification preferences: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := map[string]NotificationPreferences{}
|
||||
for rows.Next() {
|
||||
prefs := DefaultNotificationPreferences()
|
||||
var userID string
|
||||
if err := rows.Scan(&userID, &prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts,
|
||||
&prefs.RadarrAlerts, &prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts,
|
||||
&prefs.LeadDays); err != nil {
|
||||
return nil, fmt.Errorf("store: scan notification preferences: %w", err)
|
||||
}
|
||||
result[userID] = prefs
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertNotification(
|
||||
ctx context.Context, userID, sourceKey, kind, itemID, title, message string, eventAt *time.Time,
|
||||
) error {
|
||||
|
||||
@@ -217,10 +217,24 @@ CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
emby_user_id TEXT PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
show_return_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
sonarr_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
radarr_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
update_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
library_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
system_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
lead_days INT NOT NULL DEFAULT 7 CHECK (lead_days BETWEEN 1 AND 30),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- CREATE TABLE IF NOT EXISTS does not add fields to an existing installation. These
|
||||
-- additive, permissive defaults make the feature safe to roll out without changing what
|
||||
-- any current viewer receives.
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS sonarr_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS radarr_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS update_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS library_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS system_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- Notifications are materialised so read/dismissed state follows the user to every TV.
|
||||
-- source_key is deterministic, preventing the same return date from being announced
|
||||
-- again whenever the app refreshes.
|
||||
@@ -696,3 +710,33 @@ CREATE TABLE IF NOT EXISTS media_report_actions (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS media_report_actions_report_idx
|
||||
ON media_report_actions (report_id, created_at ASC);
|
||||
|
||||
-- Where an episode's closing credits begin, for the small number of episodes a household is
|
||||
-- actually about to watch. Written by internal/credits.
|
||||
--
|
||||
-- The primary key is the whole design. Keyed on the media *version* rather than on the item,
|
||||
-- so a file Sonarr replaces stops matching its old marker and becomes a scan candidate again
|
||||
-- with nothing having to notice the swap — no invalidation pass, no staleness check, and no
|
||||
-- possibility of a Skip Credits button positioned against a file that no longer exists.
|
||||
--
|
||||
-- Deliberately the only durable output of that subsystem. Queue state, candidate priorities
|
||||
-- and scan progress are all held in RAM and rebuilt from Tracearr on restart, because a
|
||||
-- persistent job scheduler would cost more writes than the scanning it coordinates.
|
||||
CREATE TABLE IF NOT EXISTS credits_markers (
|
||||
item_id TEXT NOT NULL,
|
||||
media_fingerprint TEXT NOT NULL,
|
||||
credits_start_ms BIGINT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0,
|
||||
detection_method TEXT NOT NULL DEFAULT '',
|
||||
-- Only so a season can be read back in one query. That read is what narrows the next
|
||||
-- episode's scan from ten minutes of file to two.
|
||||
series_id TEXT NOT NULL DEFAULT '',
|
||||
season_number INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (item_id, media_fingerprint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS credits_markers_season_idx
|
||||
ON credits_markers (series_id, season_number, confidence DESC)
|
||||
WHERE series_id <> '';
|
||||
|
||||
@@ -100,14 +100,18 @@ type HeroPlacementPolicy struct {
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
}
|
||||
|
||||
// HeroSchedule is resolved by the gateway for every home response. Times are UTC RFC3339;
|
||||
// weekdays use the local calendar day (Sunday=0) and an empty list means every day.
|
||||
// HeroSchedule is resolved by the gateway for every home response. An empty Frequency is
|
||||
// the original absolute start/end shape and remains valid. Daily and weekly schedules use
|
||||
// the gateway's local clock; a window such as 22:00–02:00 belongs to the day it starts.
|
||||
type HeroSchedule struct {
|
||||
ID string `json:"id"`
|
||||
ItemID string `json:"itemId"`
|
||||
StartAt time.Time `json:"startAt"`
|
||||
EndAt time.Time `json:"endAt"`
|
||||
Weekdays []int `json:"weekdays,omitempty"`
|
||||
Frequency string `json:"frequency,omitempty"`
|
||||
StartTime string `json:"startTime,omitempty"`
|
||||
EndTime string `json:"endTime,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -205,9 +209,25 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
seenSchedules := map[string]bool{}
|
||||
for _, schedule := range policy.Schedules {
|
||||
schedule.ID, schedule.ItemID, schedule.UserID = strings.TrimSpace(schedule.ID), strings.TrimSpace(schedule.ItemID), strings.TrimSpace(schedule.UserID)
|
||||
if schedule.ID == "" || schedule.ItemID == "" || seenSchedules[schedule.ID] || !schedule.EndAt.After(schedule.StartAt) {
|
||||
schedule.Frequency = strings.ToLower(strings.TrimSpace(schedule.Frequency))
|
||||
if schedule.Frequency == "once" {
|
||||
// "once" is explicit in the new console; empty is the compatible legacy form.
|
||||
schedule.Frequency = ""
|
||||
}
|
||||
recurring := schedule.Frequency == "daily" || schedule.Frequency == "weekly"
|
||||
if schedule.ID == "" || schedule.ItemID == "" || seenSchedules[schedule.ID] ||
|
||||
(!recurring && !schedule.EndAt.After(schedule.StartAt)) {
|
||||
continue
|
||||
}
|
||||
if recurring {
|
||||
schedule.StartTime = normaliseHeroClock(schedule.StartTime)
|
||||
schedule.EndTime = normaliseHeroClock(schedule.EndTime)
|
||||
if schedule.StartTime == "" || schedule.EndTime == "" || schedule.StartTime == schedule.EndTime {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
schedule.StartTime, schedule.EndTime = "", ""
|
||||
}
|
||||
seenSchedules[schedule.ID] = true
|
||||
if schedule.Priority < -1000 {
|
||||
schedule.Priority = -1000
|
||||
@@ -224,6 +244,9 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
}
|
||||
}
|
||||
schedule.Weekdays = weekdays
|
||||
if schedule.Frequency == "daily" {
|
||||
schedule.Weekdays = []int{}
|
||||
}
|
||||
placements := make([]string, 0, len(schedule.Placements))
|
||||
seenPlacements := map[string]bool{}
|
||||
for _, placement := range schedule.Placements {
|
||||
@@ -243,6 +266,14 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
return policy
|
||||
}
|
||||
|
||||
func normaliseHeroClock(value string) string {
|
||||
parsed, err := time.Parse("15:04", strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.Format("15:04")
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -84,6 +84,22 @@ func TestHeroSchedulesDefaultToHomeAndNormalisePlacementNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroPolicyKeepsRecurringSchedulesWithoutAbsoluteDates(t *testing.T) {
|
||||
got := normalizeHeroPolicy(HeroPolicy{Schedules: []HeroSchedule{
|
||||
{ID: "daily", ItemID: "one", Frequency: "DAILY", StartTime: " 18:00 ", EndTime: "22:30", Enabled: true},
|
||||
{ID: "weekly", ItemID: "two", Frequency: "weekly", StartTime: "22:00", EndTime: "02:00", Weekdays: []int{5, 5, 7}, Enabled: true},
|
||||
}})
|
||||
if len(got.Schedules) != 2 {
|
||||
t.Fatalf("recurring schedules = %+v", got.Schedules)
|
||||
}
|
||||
if got.Schedules[0].Frequency != "daily" || len(got.Schedules[0].Weekdays) != 0 {
|
||||
t.Fatalf("daily schedule = %+v", got.Schedules[0])
|
||||
}
|
||||
if got.Schedules[1].StartTime != "22:00" || len(got.Schedules[1].Weekdays) != 1 || got.Schedules[1].Weekdays[0] != 5 {
|
||||
t.Fatalf("weekly schedule = %+v", got.Schedules[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
|
||||
defaults := DefaultMDBListSettings()
|
||||
if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user