0.2.76 - Icon Packs

This commit is contained in:
ponzischeme89
2026-08-18 14:59:29 +12:00
parent 36d171e51b
commit 8c847c59b8
70 changed files with 5267 additions and 673 deletions
+11
View File
@@ -46,6 +46,14 @@ type GatewaySettings struct {
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
// EmbyHealthSeconds is how often the reachability probe asks Emby whether it is there.
EmbyHealthSeconds int `json:"embyHealthSeconds"`
// LibrarySyncMinutes is how often the catalogue sweep asks Emby what changed.
//
// It is an override worth having because the answer now depends on the household's
// wiring rather than on the gateway: with both *arr webhooks configured, a new file is
// in the catalogue within a minute of landing and the sweep is reconciliation for
// media Sonarr and Radarr do not manage — six hours rather than one. With no webhooks
// it is still the only way anything is discovered and must stay frequent.
LibrarySyncMinutes int `json:"librarySyncMinutes"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy,omitempty"`
@@ -78,6 +86,9 @@ func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
settings.SonarrAlertMinutes = clampOverride(settings.SonarrAlertMinutes, 1, 24*60, true)
settings.RadarrAlertMinutes = clampOverride(settings.RadarrAlertMinutes, 1, 7*24*60, true)
settings.EmbyHealthSeconds = clampOverride(settings.EmbyHealthSeconds, 10, 3600, true)
// A day is the ceiling rather than a week: however well the webhooks are working, the
// sweep is the only thing that ever notices a file somebody moved by hand.
settings.LibrarySyncMinutes = clampOverride(settings.LibrarySyncMinutes, 5, 24*60, true)
return settings
}
+64
View File
@@ -120,6 +120,70 @@ func (s *Store) DeleteLibraryItemsBefore(ctx context.Context, cutoff time.Time)
return tag.RowsAffected(), nil
}
// NamedItem is the least a caller can be told about a catalogue row and still identify
// it: what it is called and, where the library knows, when it came out.
type NamedItem struct {
ID string
Name string
Year int
}
// LibraryItemsByName finds catalogue rows by title, case-insensitively.
//
// The comparison that decides the answer is not this one: the caller normalises both
// sides (punctuation and spacing are where an *arr and Emby actually differ) and picks by
// year. This is the narrowing query — a handful of rows out of twenty thousand — so that
// the matching rule can stay a pure function with one definition.
func (s *Store) LibraryItemsByName(ctx context.Context, itemType, name string) ([]NamedItem, error) {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT id, name, COALESCE(production_year, 0)
FROM library_items
WHERE type = $1 AND lower(name) = lower($2)
LIMIT 50`, itemType, trimmed)
if err != nil {
return nil, fmt.Errorf("store: library items by name: %w", err)
}
defer rows.Close()
out := []NamedItem{}
for rows.Next() {
var item NamedItem
if err := rows.Scan(&item.ID, &item.Name, &item.Year); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
// DeleteLibraryItem removes one item and anything derived from it.
//
// The credits marker goes with it, and that is the point of doing this in one place: the
// marker table is keyed on the item id and nothing else prunes it, so a title deleted from
// the library would otherwise leave a Skip Credits position behind for a file that no
// longer exists — and if that id were ever reused, in front of the wrong programme.
//
// Deleting a series takes its episodes with it, because Emby's own hierarchy is the only
// thing that made those rows meaningful.
func (s *Store) DeleteLibraryItem(ctx context.Context, itemID string) (int64, error) {
if strings.TrimSpace(itemID) == "" {
return 0, nil
}
tag, err := s.pool.Exec(ctx,
`DELETE FROM library_items WHERE id = $1 OR series_id = $1`, itemID)
if err != nil {
return 0, fmt.Errorf("store: delete library item: %w", err)
}
if _, err := s.pool.Exec(ctx,
`DELETE FROM credits_markers WHERE item_id = $1 OR series_id = $1`, itemID); err != nil {
return 0, fmt.Errorf("store: delete credits markers: %w", err)
}
return tag.RowsAffected(), nil
}
// SearchLibrary answers from the imported library rather than Emby.
//
// Full-text match first, with a trailing ILIKE so partial words ("sever") still hit
+216
View File
@@ -0,0 +1,216 @@
package store
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// The durable side of event-driven ingest.
//
// One table, five queries, and the only interesting one is the insert: it is written
// ON CONFLICT on a key derived from the file, which is the whole of what makes repeated
// webhook delivery safe. Sonarr and Radarr both re-notify on retry and neither guarantees
// exactly-once, so "the same news twice" has to be an ordinary event rather than a
// duplicate row and a duplicate Emby lookup.
// Ingest states.
const (
IngestPending = "pending"
IngestDone = "done"
IngestFailed = "failed"
)
// IngestRetention is how long settled rows are kept. Long enough that an operator asking
// "did the webhook fire when that episode landed last week" gets an answer, short enough
// that a household importing all day does not accumulate a table nobody reads.
const IngestRetention = 14 * 24 * time.Hour
// IngestJob is one row of work.
type IngestJob struct {
Key string `json:"key"`
Action string `json:"action"`
Kind string `json:"kind"`
Reason string `json:"reason"`
Source string `json:"source"`
Payload json.RawMessage `json:"payload"`
State string `json:"state"`
Outcome string `json:"outcome"`
ItemID string `json:"itemId"`
Attempts int `json:"attempts"`
LastError string `json:"lastError"`
DueAt time.Time `json:"dueAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// IngestCounts is what the console reads beside the list.
type IngestCounts struct {
Pending int `json:"pending"`
Done int `json:"done"`
Failed int `json:"failed"`
}
// EnqueueIngest records a piece of work, or refreshes one already waiting.
//
// The second return reports whether this delivery was news. A repeat is not an error and
// not a second row — it moves the existing row's due time no earlier and is logged at
// DEBUG, because a Sonarr that retried is an ordinary occurrence and not something an
// operator needs told about.
//
// A key that has already been *settled* is deliberately re-opened: the same file can
// legitimately be imported, deleted and imported again, and a row left at 'done' would
// swallow the second import for ever.
func (s *Store) EnqueueIngest(ctx context.Context, job IngestJob) (bool, error) {
if job.Key == "" || job.Action == "" {
return false, fmt.Errorf("store: ingest job needs a key and an action")
}
if len(job.Payload) == 0 {
job.Payload = json.RawMessage(`{}`)
}
if job.DueAt.IsZero() {
job.DueAt = time.Now().UTC()
}
var inserted bool
err := s.pool.QueryRow(ctx, `
INSERT INTO library_ingest_queue
(key, action, kind, reason, source, payload, state, due_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7, now(), now())
ON CONFLICT (key) DO UPDATE SET
action = EXCLUDED.action,
kind = EXCLUDED.kind,
reason = EXCLUDED.reason,
source = EXCLUDED.source,
payload = EXCLUDED.payload,
state = 'pending',
outcome = '',
last_error = '',
-- A re-delivery must never pull the settle delay forward: the point of it is
-- that the file has finished being written, and an eager retry would ask Emby
-- about a file it has not scanned yet.
due_at = GREATEST(library_ingest_queue.due_at, EXCLUDED.due_at),
-- Attempts reset only when the row had settled. A retry storm against a row
-- still being worked must not reset its backoff.
attempts = CASE WHEN library_ingest_queue.state = 'pending'
THEN library_ingest_queue.attempts ELSE 0 END,
updated_at = now()
RETURNING (xmax = 0)`,
job.Key, job.Action, job.Kind, job.Reason, job.Source, job.Payload, job.DueAt.UTC(),
).Scan(&inserted)
if err != nil {
return false, fmt.Errorf("store: enqueue ingest: %w", err)
}
return inserted, nil
}
// ClaimIngest takes the work that is due, oldest first.
//
// It marks nothing: the worker is single and in-process, so a claim flag would be state to
// get wrong (a row left claimed by a container that was killed) in exchange for protecting
// against a second worker that does not exist. FinishIngest is what moves a row on.
func (s *Store) ClaimIngest(ctx context.Context, now time.Time, limit int) ([]IngestJob, error) {
if limit <= 0 {
limit = 10
}
rows, err := s.pool.Query(ctx, `
SELECT key, action, kind, reason, source, payload, state, outcome, item_id,
attempts, last_error, due_at, created_at, updated_at
FROM library_ingest_queue
WHERE state = 'pending' AND due_at <= $1
ORDER BY due_at
LIMIT $2`, now.UTC(), limit)
if err != nil {
return nil, fmt.Errorf("store: claim ingest: %w", err)
}
defer rows.Close()
return scanIngestJobs(rows)
}
// FinishIngest settles a row, or schedules the next attempt.
//
// state is 'done', 'failed' or 'pending' — the last being a deferral, which is the
// ordinary answer for a file Emby has not scanned in yet.
func (s *Store) FinishIngest(
ctx context.Context, key, state, outcome, itemID, errorText string, retryAt time.Time,
) error {
due := retryAt
if due.IsZero() {
due = time.Now().UTC()
}
_, err := s.pool.Exec(ctx, `
UPDATE library_ingest_queue
SET state = $2, outcome = $3, item_id = $4, last_error = $5,
attempts = attempts + 1, due_at = $6, updated_at = now()
WHERE key = $1`, key, state, outcome, itemID, errorText, due.UTC())
if err != nil {
return fmt.Errorf("store: finish ingest: %w", err)
}
return nil
}
// RecentIngests is the console's read: newest activity first, whatever its state.
func (s *Store) RecentIngests(ctx context.Context, limit int) ([]IngestJob, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT key, action, kind, reason, source, payload, state, outcome, item_id,
attempts, last_error, due_at, created_at, updated_at
FROM library_ingest_queue
ORDER BY updated_at DESC
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("store: recent ingests: %w", err)
}
defer rows.Close()
return scanIngestJobs(rows)
}
// IngestStateCounts is the summary above that list.
func (s *Store) IngestStateCounts(ctx context.Context) (IngestCounts, error) {
var counts IngestCounts
err := s.pool.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (WHERE state = 'pending'),
COUNT(*) FILTER (WHERE state = 'done'),
COUNT(*) FILTER (WHERE state = 'failed')
FROM library_ingest_queue`).Scan(&counts.Pending, &counts.Done, &counts.Failed)
if err != nil {
return IngestCounts{}, fmt.Errorf("store: ingest counts: %w", err)
}
return counts, nil
}
// PruneIngests removes settled rows past their retention. Pending work is never pruned:
// a row still waiting is work nobody has done, however old it is.
func (s *Store) PruneIngests(ctx context.Context, retention time.Duration) (int64, error) {
if retention <= 0 {
return 0, nil
}
tag, err := s.pool.Exec(ctx, `
DELETE FROM library_ingest_queue
WHERE state <> 'pending' AND updated_at < $1`, time.Now().UTC().Add(-retention))
if err != nil {
return 0, fmt.Errorf("store: prune ingests: %w", err)
}
return tag.RowsAffected(), nil
}
func scanIngestJobs(rows pgx.Rows) ([]IngestJob, error) {
out := []IngestJob{}
for rows.Next() {
var job IngestJob
if err := rows.Scan(
&job.Key, &job.Action, &job.Kind, &job.Reason, &job.Source, &job.Payload,
&job.State, &job.Outcome, &job.ItemID, &job.Attempts, &job.LastError,
&job.DueAt, &job.CreatedAt, &job.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, job)
}
return out, rows.Err()
}
+39
View File
@@ -792,3 +792,42 @@ CREATE INDEX IF NOT EXISTS credits_scan_history_item_time_idx
ON credits_scan_history (item_id, finished_at DESC);
CREATE INDEX IF NOT EXISTS credits_scan_history_time_idx
ON credits_scan_history (finished_at DESC);
-- Work Sonarr and Radarr told the gateway about.
--
-- This is the one queue in the schema that is durable, and the reason is that a webhook is
-- gone once it has been dropped: a Tracearr-derived credits candidate is rebuilt from one
-- query on restart, while "Sonarr imported this at 19:05" cannot be rederived from
-- anything. A container restarted during the settle delay must still re-read the file.
--
-- The key is derived from the *file* rather than from the delivery, so ON CONFLICT is what
-- makes repeated webhook delivery safe: two notifications about one import collapse onto
-- one row, while a file deleted and re-imported is a different file and its own work.
--
-- Completed rows are kept rather than deleted. They are the operator's record of why an
-- item was re-read, which is the question the Imports page exists to answer; housekeeping
-- prunes them.
CREATE TABLE IF NOT EXISTS library_ingest_queue (
key TEXT PRIMARY KEY,
action TEXT NOT NULL,
kind TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
state TEXT NOT NULL DEFAULT 'pending',
outcome TEXT NOT NULL DEFAULT '',
item_id TEXT NOT NULL DEFAULT '',
attempts INT NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
due_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- The worker's only query: what is due. Partial, because settled rows outnumber pending
-- ones by orders of magnitude within a day of the feature being switched on.
CREATE INDEX IF NOT EXISTS library_ingest_pending_idx
ON library_ingest_queue (due_at)
WHERE state = 'pending';
CREATE INDEX IF NOT EXISTS library_ingest_recent_idx
ON library_ingest_queue (updated_at DESC);