0.2.64 update

This commit is contained in:
ponzischeme89
2026-08-15 09:23:26 +12:00
parent a2ca7e8061
commit d5d47473a2
90 changed files with 9188 additions and 451 deletions
+293
View File
@@ -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()
}
+50 -6
View File
@@ -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 {
+44
View File
@@ -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 <> '';
+34 -3
View File
@@ -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:0002: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)
+16
View File
@@ -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 {