0.2.78
This commit is contained in:
@@ -64,6 +64,14 @@ type TracearrImportState struct {
|
||||
LastIncrementalAt *time.Time `json:"lastIncrementalAt,omitempty"`
|
||||
LastFullAt *time.Time `json:"lastFullAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
// LastRebuildAt is when the household's For You rows were last rebuilt in full.
|
||||
//
|
||||
// It lives in this document rather than in a table of its own because it is the same
|
||||
// kind of fact as the two stamps above it — where the For You pipeline has got to —
|
||||
// and because the daily rebuild is now a scheduled task, which means the alternative
|
||||
// was inferring "did today's rebuild happen" from run history that also records the
|
||||
// ticks on which it correctly declined to run.
|
||||
LastRebuildAt *time.Time `json:"lastRebuildAt,omitempty"`
|
||||
}
|
||||
|
||||
type RecommendationProfile struct {
|
||||
@@ -407,6 +415,20 @@ func (s *Store) SetTracearrImportState(ctx context.Context, state TracearrImport
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkForYouRebuild records that the household's rows have just been rebuilt.
|
||||
//
|
||||
// Read-modify-write rather than a whole-document put, because the importer owns the other
|
||||
// two stamps in this document and an import running beside a rebuild must not lose its own.
|
||||
func (s *Store) MarkForYouRebuild(ctx context.Context, at time.Time) error {
|
||||
state, err := s.TracearrImportState(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stamp := at.UTC()
|
||||
state.LastRebuildAt = &stamp
|
||||
return s.SetTracearrImportState(ctx, state)
|
||||
}
|
||||
|
||||
func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT ON (emby_user_id)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// IntegrationPolicyKey is the operator's global on/off switch for external services that
|
||||
// have no configuration document of their own.
|
||||
//
|
||||
// It is deliberately *not* a second copy of every integration's switch. Sonarr and Radarr
|
||||
// already store theirs in the arr integration policy and MDBList stores its own in the
|
||||
// ratings settings; moving those here would mean a migration and, worse, a window in which
|
||||
// two documents disagreed about whether a service was on. The rule is one stored truth per
|
||||
// integration, kept where that integration's other configuration already lives — and this
|
||||
// document is that home for the ones that have nowhere else, which today is Tracearr.
|
||||
//
|
||||
// The API's integrationEnabled/setIntegrationEnabled pair is the single reader and writer,
|
||||
// so the console never has to know which document answers for which service.
|
||||
const IntegrationPolicyKey = "integration_policy"
|
||||
|
||||
// IntegrationPolicy is a map of integration id to whether it is switched on.
|
||||
//
|
||||
// Absence means on. A household that upgrades into this feature has every service it had
|
||||
// configured still working, which is the only safe reading: the alternative silently turns
|
||||
// off recommendation imports on the day the console gained a switch for them.
|
||||
type IntegrationPolicy struct {
|
||||
Disabled map[string]bool `json:"disabled"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Enabled reports whether one integration is switched on. A service nobody has ever
|
||||
// touched has no entry, and no entry is on.
|
||||
func (p IntegrationPolicy) Enabled(id string) bool {
|
||||
return !p.Disabled[strings.TrimSpace(id)]
|
||||
}
|
||||
|
||||
func (s *Store) IntegrationPolicy(ctx context.Context) (IntegrationPolicy, error) {
|
||||
empty := IntegrationPolicy{Disabled: map[string]bool{}}
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT value FROM app_settings WHERE key = $1`, IntegrationPolicyKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return empty, nil
|
||||
}
|
||||
if err != nil {
|
||||
return empty, fmt.Errorf("store: read integration policy: %w", err)
|
||||
}
|
||||
var policy IntegrationPolicy
|
||||
if err := json.Unmarshal(raw, &policy); err != nil {
|
||||
return empty, fmt.Errorf("store: decode integration policy: %w", err)
|
||||
}
|
||||
if policy.Disabled == nil {
|
||||
policy.Disabled = map[string]bool{}
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// SetIntegrationEnabled records one service's switch, leaving every other entry alone.
|
||||
//
|
||||
// Read-modify-write rather than a whole-document put, because the console sends one
|
||||
// switch at a time and a put would let a page rendered before another integration existed
|
||||
// silently re-enable it.
|
||||
func (s *Store) SetIntegrationEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("store: integration policy needs an id")
|
||||
}
|
||||
policy, err := s.IntegrationPolicy(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled {
|
||||
delete(policy.Disabled, id)
|
||||
} else {
|
||||
policy.Disabled[id] = true
|
||||
}
|
||||
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()`,
|
||||
IntegrationPolicyKey, string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write integration policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -223,6 +223,40 @@ func (s *Store) MediaRatingsStats(ctx context.Context, staleBefore time.Time) (t
|
||||
return total, stale, nil
|
||||
}
|
||||
|
||||
// StaleRatingKeys is the oldest stored titles due to be re-checked, oldest first.
|
||||
//
|
||||
// Oldest first rather than by any measure of popularity, because the refresh is bounded
|
||||
// per run: taking the oldest means every title comes round eventually, where taking the
|
||||
// most-watched would leave the tail of the library permanently on scores from the year it
|
||||
// was imported. The limit is what keeps a run's cost — and therefore the day's external
|
||||
// allowance — a figure the operator can reason about.
|
||||
func (s *Store) StaleRatingKeys(
|
||||
ctx context.Context, staleBefore time.Time, limit int,
|
||||
) ([]RatingKey, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT media_type, provider, provider_id
|
||||
FROM external_media_ratings
|
||||
WHERE fetched_at < $1
|
||||
ORDER BY fetched_at ASC
|
||||
LIMIT $2`, staleBefore, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: stale rating keys: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
keys := []RatingKey{}
|
||||
for rows.Next() {
|
||||
var key RatingKey
|
||||
if err := rows.Scan(&key.MediaType, &key.Provider, &key.ProviderID); err != nil {
|
||||
return nil, fmt.Errorf("store: scan stale rating key: %w", err)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// SaveMediaRatings upserts a successfully loaded provider response.
|
||||
func (s *Store) SaveMediaRatings(
|
||||
ctx context.Context, mediaType, provider, providerID string, ratings json.RawMessage,
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// MediaRequest is one viewer's ask, as recorded. It carries no status: see the schema
|
||||
// comment on media_requests for why the state is derived per read rather than stored.
|
||||
// MediaRequest is one viewer's ask, as recorded.
|
||||
//
|
||||
// The status a card shows is not here: it is derived per read from the *arrs and the
|
||||
// library, for the reason the schema gives. The one exception is LastStatus, which is not
|
||||
// the card's status but the memory of it — the only way to notice that something changed.
|
||||
type MediaRequest struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
ForeignID int `json:"foreignId"`
|
||||
@@ -16,6 +19,19 @@ type MediaRequest struct {
|
||||
Year int `json:"year,omitempty"`
|
||||
PosterURL string `json:"posterUrl,omitempty"`
|
||||
RequestedAt time.Time `json:"requestedAt"`
|
||||
// LastStatus is the state this request was in the last time anything looked, and it is
|
||||
// the only piece of request state that is stored. See the schema comment: an arrival is
|
||||
// a difference between two observations rather than a property of one, and the viewer is
|
||||
// told about it once.
|
||||
LastStatus string `json:"-"`
|
||||
}
|
||||
|
||||
// OwnedMediaRequest is a stored ask with the person who made it, which the per-viewer read
|
||||
// does not need to carry because it was asked for by user. The ready sweep looks at the
|
||||
// whole household in one query, so there it is the point.
|
||||
type OwnedMediaRequest struct {
|
||||
MediaRequest
|
||||
UserID string
|
||||
}
|
||||
|
||||
// RequestUsage is the operator-facing use of the request feature. A recorded request is
|
||||
@@ -59,16 +75,20 @@ func (s *Store) SaveMediaRequest(ctx context.Context, userID string, req MediaRe
|
||||
if userID == "" || req.ForeignID <= 0 {
|
||||
return fmt.Errorf("store: media request needs a user and a foreign id")
|
||||
}
|
||||
// last_status is deliberately absent from the UPDATE. Asking again is somebody saying
|
||||
// they still want it, not a reason to re-announce an arrival they were already told
|
||||
// about — and re-seeding it here would make a second press of Request the way to make
|
||||
// the gateway repeat itself.
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO media_requests
|
||||
(emby_user_id, media_type, foreign_id, title, year, poster_url, requested_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now())
|
||||
(emby_user_id, media_type, foreign_id, title, year, poster_url, last_status, requested_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||
ON CONFLICT (emby_user_id, media_type, foreign_id) DO UPDATE
|
||||
SET title = EXCLUDED.title,
|
||||
year = EXCLUDED.year,
|
||||
poster_url = EXCLUDED.poster_url,
|
||||
requested_at = now()`,
|
||||
userID, req.MediaType, req.ForeignID, req.Title, req.Year, req.PosterURL)
|
||||
userID, req.MediaType, req.ForeignID, req.Title, req.Year, req.PosterURL, req.LastStatus)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: save media request: %w", err)
|
||||
}
|
||||
@@ -78,7 +98,7 @@ func (s *Store) SaveMediaRequest(ctx context.Context, userID string, req MediaRe
|
||||
// MediaRequests returns one viewer's asks, most recent first.
|
||||
func (s *Store) MediaRequests(ctx context.Context, userID string) ([]MediaRequest, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT media_type, foreign_id, title, year, poster_url, requested_at
|
||||
SELECT media_type, foreign_id, title, year, poster_url, last_status, requested_at
|
||||
FROM media_requests
|
||||
WHERE emby_user_id = $1
|
||||
ORDER BY requested_at DESC
|
||||
@@ -92,7 +112,8 @@ func (s *Store) MediaRequests(ctx context.Context, userID string) ([]MediaReques
|
||||
for rows.Next() {
|
||||
var req MediaRequest
|
||||
if err := rows.Scan(
|
||||
&req.MediaType, &req.ForeignID, &req.Title, &req.Year, &req.PosterURL, &req.RequestedAt,
|
||||
&req.MediaType, &req.ForeignID, &req.Title, &req.Year, &req.PosterURL,
|
||||
&req.LastStatus, &req.RequestedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan media request: %w", err)
|
||||
}
|
||||
@@ -118,3 +139,60 @@ func (s *Store) DeleteMediaRequest(
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllMediaRequests reads the whole household's asks, newest first, with the person attached.
|
||||
//
|
||||
// The per-viewer read above is what a page needs; this is what the ready sweep needs, and
|
||||
// the difference is worth one query rather than one per account: a household of six with
|
||||
// eighty requests between them is one read, and the sweep has to look at all of them anyway
|
||||
// because two people can be waiting for the same film.
|
||||
//
|
||||
// It is bounded like the per-viewer read. A sweep that fell behind on a household which had
|
||||
// been asking for things for two years must not become an unbounded query on a timer.
|
||||
func (s *Store) AllMediaRequests(ctx context.Context, limit int) ([]OwnedMediaRequest, error) {
|
||||
if limit <= 0 {
|
||||
limit = MediaRequestSweepLimit
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT emby_user_id, media_type, foreign_id, title, year, poster_url, last_status, requested_at
|
||||
FROM media_requests
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: read all media requests: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
requests := []OwnedMediaRequest{}
|
||||
for rows.Next() {
|
||||
var req OwnedMediaRequest
|
||||
if err := rows.Scan(
|
||||
&req.UserID, &req.MediaType, &req.ForeignID, &req.Title, &req.Year,
|
||||
&req.PosterURL, &req.LastStatus, &req.RequestedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan media request: %w", err)
|
||||
}
|
||||
requests = append(requests, req)
|
||||
}
|
||||
return requests, rows.Err()
|
||||
}
|
||||
|
||||
// MediaRequestSweepLimit caps what one pass of the ready sweep will look at.
|
||||
const MediaRequestSweepLimit = 500
|
||||
|
||||
// SetMediaRequestStatus records what a request was last seen doing.
|
||||
//
|
||||
// Written only when the state actually moved, so a sweep over a household where nothing has
|
||||
// changed — which is almost every sweep — costs no writes at all.
|
||||
func (s *Store) SetMediaRequestStatus(
|
||||
ctx context.Context, userID, mediaType string, foreignID int, status string,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE media_requests SET last_status = $4
|
||||
WHERE emby_user_id = $1 AND media_type = $2 AND foreign_id = $3`,
|
||||
strings.TrimSpace(userID), mediaType, foreignID, status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set media request status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,17 +29,44 @@ const (
|
||||
TriggerStartup = "startup"
|
||||
)
|
||||
|
||||
// RunCounts is what a run did, in numbers.
|
||||
//
|
||||
// Four figures rather than a free map, because these are the four questions an operator
|
||||
// asks of any piece of batch work — how much did it look at, how much did it change, how
|
||||
// much did it decline, how much went wrong — and a schema-free bag would let two
|
||||
// integrations answer them under different names. Anything an integration counts beyond
|
||||
// these belongs in the run's own sentence.
|
||||
//
|
||||
// Processed is the load-bearing one: a run reporting zero processed counted nothing at
|
||||
// all, and the console draws no figures rather than four zeroes.
|
||||
type RunCounts struct {
|
||||
Processed int `json:"processed"`
|
||||
Changed int `json:"changed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
// Counted reports whether this run counted anything worth printing.
|
||||
func (c RunCounts) Counted() bool {
|
||||
return c.Processed != 0 || c.Changed != 0 || c.Skipped != 0 || c.Failed != 0
|
||||
}
|
||||
|
||||
// TaskRun is one execution.
|
||||
type TaskRun struct {
|
||||
ID int64 `json:"id"`
|
||||
TaskID string `json:"taskId"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
TaskID string `json:"taskId"`
|
||||
// IntegrationID names the external service this run belongs to, and is empty for the
|
||||
// gateway's own housekeeping. It is what lets the integrations area read operational
|
||||
// history out of the scheduler's own table instead of keeping a second one.
|
||||
IntegrationID string `json:"integrationId,omitempty"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Counts RunCounts `json:"counts"`
|
||||
}
|
||||
|
||||
// TaskSettings is an operator's override for one task. IntervalSeconds of 0 means "the
|
||||
@@ -55,24 +82,31 @@ type TaskSettings struct {
|
||||
// BeginTaskRun opens a run and returns its id. The row exists before the work starts so a
|
||||
// task killed by a restart leaves evidence it began — which is the only way to tell a job
|
||||
// that hangs from one that was never scheduled.
|
||||
func (s *Store) BeginTaskRun(ctx context.Context, taskID, trigger string) (int64, error) {
|
||||
func (s *Store) BeginTaskRun(
|
||||
ctx context.Context, taskID, integrationID, trigger string,
|
||||
) (int64, error) {
|
||||
var id int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO scheduled_task_runs (task_id, trigger, status)
|
||||
VALUES ($1, $2, $3) RETURNING id`, taskID, trigger, TaskRunning).Scan(&id)
|
||||
INSERT INTO scheduled_task_runs (task_id, integration_id, trigger, status)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
taskID, integrationID, trigger, TaskRunning).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: begin task run: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// FinishTaskRun closes a run with its outcome.
|
||||
func (s *Store) FinishTaskRun(ctx context.Context, id int64, status, detail, failure string) error {
|
||||
// FinishTaskRun closes a run with its outcome and whatever it counted.
|
||||
func (s *Store) FinishTaskRun(
|
||||
ctx context.Context, id int64, status, detail, failure string, counts RunCounts,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE scheduled_task_runs
|
||||
SET status = $2, finished_at = now(), detail = $3, error = $4,
|
||||
processed = $5, changed = $6, skipped = $7, failed = $8,
|
||||
duration_ms = GREATEST(0, EXTRACT(EPOCH FROM (now() - started_at)) * 1000)::bigint
|
||||
WHERE id = $1`, id, status, detail, failure)
|
||||
WHERE id = $1`, id, status, detail, failure,
|
||||
counts.Processed, counts.Changed, counts.Skipped, counts.Failed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: finish task run: %w", err)
|
||||
}
|
||||
@@ -103,7 +137,8 @@ func (s *Store) AbandonRunningTasks(ctx context.Context) (int64, error) {
|
||||
func (s *Store) LatestTaskRuns(ctx context.Context) (map[string]TaskRun, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT ON (task_id)
|
||||
id, task_id, trigger, status, started_at, finished_at, duration_ms, detail, error
|
||||
id, task_id, integration_id, trigger, status, started_at, finished_at,
|
||||
duration_ms, detail, error, processed, changed, skipped, failed
|
||||
FROM scheduled_task_runs
|
||||
ORDER BY task_id, started_at DESC, id DESC`)
|
||||
if err != nil {
|
||||
@@ -113,9 +148,10 @@ func (s *Store) LatestTaskRuns(ctx context.Context) (map[string]TaskRun, error)
|
||||
latest := map[string]TaskRun{}
|
||||
for rows.Next() {
|
||||
var run TaskRun
|
||||
if err := rows.Scan(&run.ID, &run.TaskID, &run.Trigger, &run.Status,
|
||||
&run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
|
||||
&run.Error); err != nil {
|
||||
if err := rows.Scan(&run.ID, &run.TaskID, &run.IntegrationID, &run.Trigger,
|
||||
&run.Status, &run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
|
||||
&run.Error, &run.Counts.Processed, &run.Counts.Changed,
|
||||
&run.Counts.Skipped, &run.Counts.Failed); err != nil {
|
||||
return nil, fmt.Errorf("store: scan task run: %w", err)
|
||||
}
|
||||
latest[run.TaskID] = run
|
||||
@@ -129,7 +165,8 @@ func (s *Store) TaskRuns(ctx context.Context, taskID string, limit int) ([]TaskR
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, task_id, trigger, status, started_at, finished_at, duration_ms, detail, error
|
||||
SELECT id, task_id, integration_id, trigger, status, started_at, finished_at,
|
||||
duration_ms, detail, error, processed, changed, skipped, failed
|
||||
FROM scheduled_task_runs
|
||||
WHERE ($1 = '' OR task_id = $1)
|
||||
ORDER BY started_at DESC, id DESC
|
||||
@@ -141,9 +178,10 @@ func (s *Store) TaskRuns(ctx context.Context, taskID string, limit int) ([]TaskR
|
||||
runs := []TaskRun{}
|
||||
for rows.Next() {
|
||||
var run TaskRun
|
||||
if err := rows.Scan(&run.ID, &run.TaskID, &run.Trigger, &run.Status,
|
||||
&run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
|
||||
&run.Error); err != nil {
|
||||
if err := rows.Scan(&run.ID, &run.TaskID, &run.IntegrationID, &run.Trigger,
|
||||
&run.Status, &run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
|
||||
&run.Error, &run.Counts.Processed, &run.Counts.Changed,
|
||||
&run.Counts.Skipped, &run.Counts.Failed); err != nil {
|
||||
return nil, fmt.Errorf("store: scan task run: %w", err)
|
||||
}
|
||||
runs = append(runs, run)
|
||||
@@ -338,3 +376,93 @@ func (s *Store) PruneIntegrationDeliveries(ctx context.Context, keep int) (int64
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// --- integration run history ------------------------------------------------------
|
||||
//
|
||||
// The same rows as above, read with a different question in mind. An integration run *is*
|
||||
// a scheduled task run: giving the integrations area a table of its own would mean two
|
||||
// schedulers, two retention jobs and two places one piece of work could be recorded as
|
||||
// having failed. What differs is only the axis — by service rather than by job.
|
||||
|
||||
// IntegrationRuns is the operational history for one external service, newest first.
|
||||
func (s *Store) IntegrationRuns(
|
||||
ctx context.Context, integrationID string, limit int,
|
||||
) ([]TaskRun, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, task_id, integration_id, trigger, status, started_at, finished_at,
|
||||
duration_ms, detail, error, processed, changed, skipped, failed
|
||||
FROM scheduled_task_runs
|
||||
WHERE integration_id <> '' AND ($1 = '' OR integration_id = $1)
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT $2`, integrationID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list integration runs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
runs := []TaskRun{}
|
||||
for rows.Next() {
|
||||
var run TaskRun
|
||||
if err := rows.Scan(&run.ID, &run.TaskID, &run.IntegrationID, &run.Trigger,
|
||||
&run.Status, &run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
|
||||
&run.Error, &run.Counts.Processed, &run.Counts.Changed,
|
||||
&run.Counts.Skipped, &run.Counts.Failed); err != nil {
|
||||
return nil, fmt.Errorf("store: scan integration run: %w", err)
|
||||
}
|
||||
runs = append(runs, run)
|
||||
}
|
||||
return runs, rows.Err()
|
||||
}
|
||||
|
||||
// IntegrationRunSummary answers the overview's whole row for one service.
|
||||
//
|
||||
// Last success and last failure are both carried because they are different questions and
|
||||
// the answer to one is not the absence of the other: a service that failed an hour ago and
|
||||
// has worked since is healthy, and one that succeeded last week and has failed every hour
|
||||
// since is not. Neither is derivable from a single "last run".
|
||||
type IntegrationRunSummary struct {
|
||||
IntegrationID string `json:"integrationId"`
|
||||
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
|
||||
LastFailureAt *time.Time `json:"lastFailureAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Runs int `json:"runs"`
|
||||
Failures int `json:"failures"`
|
||||
}
|
||||
|
||||
// IntegrationRunSummaries is every service's summary in one query, because the overview
|
||||
// draws one per row and a query per integration would grow with the catalogue.
|
||||
func (s *Store) IntegrationRunSummaries(
|
||||
ctx context.Context,
|
||||
) (map[string]IntegrationRunSummary, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT integration_id,
|
||||
max(started_at) FILTER (WHERE status = $1),
|
||||
max(started_at) FILTER (WHERE status = $2),
|
||||
(array_remove(array_agg(error ORDER BY started_at DESC)
|
||||
FILTER (WHERE status = $2), ''))[1],
|
||||
count(*), count(*) FILTER (WHERE status = $2)
|
||||
FROM scheduled_task_runs
|
||||
WHERE integration_id <> ''
|
||||
GROUP BY integration_id`, TaskSuccess, TaskFailed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: integration run summaries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
summaries := map[string]IntegrationRunSummary{}
|
||||
for rows.Next() {
|
||||
var summary IntegrationRunSummary
|
||||
var lastError *string
|
||||
if err := rows.Scan(&summary.IntegrationID, &summary.LastSuccessAt,
|
||||
&summary.LastFailureAt, &lastError, &summary.Runs,
|
||||
&summary.Failures); err != nil {
|
||||
return nil, fmt.Errorf("store: scan integration run summary: %w", err)
|
||||
}
|
||||
if lastError != nil {
|
||||
summary.LastError = *lastError
|
||||
}
|
||||
summaries[summary.IntegrationID] = summary
|
||||
}
|
||||
return summaries, rows.Err()
|
||||
}
|
||||
|
||||
@@ -577,6 +577,17 @@ CREATE TABLE IF NOT EXISTS media_requests (
|
||||
PRIMARY KEY (emby_user_id, media_type, foreign_id)
|
||||
);
|
||||
|
||||
-- last_status is the one piece of request state that *is* stored, and only because a
|
||||
-- transition cannot be derived from a single read. Everything else on a request card is
|
||||
-- computed per read from the *arrs and the library; "it has just become ready" is not a
|
||||
-- property of the present, it is the difference between two observations, and the viewer
|
||||
-- has to be told about it exactly once.
|
||||
--
|
||||
-- It is seeded when the request is recorded rather than left blank for a sweep to fill in,
|
||||
-- because a film that downloads in the three minutes before the first sweep would otherwise
|
||||
-- have its arrival recorded as its opening state and nobody would ever be told.
|
||||
ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS last_status TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx
|
||||
ON media_requests (emby_user_id, requested_at DESC);
|
||||
|
||||
@@ -663,6 +674,28 @@ CREATE TABLE IF NOT EXISTS scheduled_task_runs (
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Which integration a run belongs to, and what it actually did.
|
||||
--
|
||||
-- Deliberately more columns on this table rather than a second one: an integration run IS
|
||||
-- a scheduled task run, read with a different question in mind. Operations history and
|
||||
-- "what does the gateway do in the background" are the same rows; giving integrations
|
||||
-- their own table would mean two schedulers, two retention jobs and two places a run can
|
||||
-- be recorded as having failed.
|
||||
--
|
||||
-- The counters are nullable-by-default zeroes because most tasks count nothing: a
|
||||
-- housekeeping prune has one number and it is already in `detail`. A run that counted
|
||||
-- nothing is drawn without figures rather than as four zeroes, which is why the API sends
|
||||
-- them only when `processed` is non-zero.
|
||||
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS integration_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS processed INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS changed INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS skipped INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS failed INT NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS scheduled_task_runs_integration_idx
|
||||
ON scheduled_task_runs (integration_id, started_at DESC)
|
||||
WHERE integration_id <> '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS scheduled_task_runs_task_idx
|
||||
ON scheduled_task_runs (task_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS scheduled_task_runs_time_idx
|
||||
|
||||
Reference in New Issue
Block a user