0.2.68
This commit is contained in:
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -9,12 +10,159 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const CreditsSettingsKey = "credits_settings"
|
||||
|
||||
// CreditsSettings is the durable operator tuning for candidate generation. Environment
|
||||
// values remain the first-run defaults; this row exists only after the console saves an
|
||||
// explicit choice.
|
||||
type CreditsSettings struct {
|
||||
CandidateLimit int `json:"candidateLimit"`
|
||||
PrefetchEpisodes int `json:"prefetchEpisodes"`
|
||||
MaxPrefetch int `json:"maxPrefetch"`
|
||||
RetryHours int `json:"retryHours"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (s *Store) CreditsSettings(ctx context.Context) (CreditsSettings, bool, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, CreditsSettingsKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return CreditsSettings{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return CreditsSettings{}, false, fmt.Errorf("store: read credits settings: %w", err)
|
||||
}
|
||||
var settings CreditsSettings
|
||||
if err := json.Unmarshal(raw, &settings); err != nil {
|
||||
return CreditsSettings{}, false, fmt.Errorf("store: decode credits settings: %w", err)
|
||||
}
|
||||
return settings, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetCreditsSettings(ctx context.Context, settings CreditsSettings) error {
|
||||
settings.UpdatedAt = time.Now().UTC()
|
||||
raw, err := json.Marshal(settings)
|
||||
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()`,
|
||||
CreditsSettingsKey, string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write credits settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreditsScanHistoryRow is one completed scan, enriched with current library names for the
|
||||
// console. Names are not copied into the history table, so metadata corrections appear here.
|
||||
type CreditsScanHistoryRow struct {
|
||||
ID int64 `json:"id"`
|
||||
ItemID string `json:"itemId"`
|
||||
ItemName string `json:"itemName"`
|
||||
SeriesName string `json:"seriesName"`
|
||||
SeriesID string `json:"seriesId"`
|
||||
Season int `json:"season"`
|
||||
Episode int `json:"episode"`
|
||||
Reason string `json:"reason"`
|
||||
Priority int `json:"priority"`
|
||||
Outcome string `json:"outcome"`
|
||||
MarkerMs int64 `json:"markerMs"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Method string `json:"method"`
|
||||
Frames int `json:"frames"`
|
||||
Error string `json:"error"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
func (s *Store) SaveCreditsScanAttempt(ctx context.Context, row CreditsScanHistoryRow) error {
|
||||
duration := row.FinishedAt.Sub(row.StartedAt).Milliseconds()
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO credits_scan_history (
|
||||
item_id, series_id, season_number, episode_number, reason, priority, outcome,
|
||||
marker_ms, confidence, method, frames_sampled, error_text,
|
||||
started_at, finished_at, duration_ms
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
|
||||
row.ItemID, row.SeriesID, row.Season, row.Episode, row.Reason, row.Priority,
|
||||
row.Outcome, row.MarkerMs, row.Confidence, row.Method, row.Frames, row.Error,
|
||||
row.StartedAt, row.FinishedAt, duration)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: save credits scan history: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RecentCreditsScanTimes(
|
||||
ctx context.Context, itemIDs []string, since time.Time,
|
||||
) (map[string]time.Time, error) {
|
||||
out := map[string]time.Time{}
|
||||
if len(itemIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT item_id, max(finished_at)
|
||||
FROM credits_scan_history
|
||||
WHERE item_id = ANY($1) AND finished_at >= $2
|
||||
AND outcome IN ('no_match', 'failed')
|
||||
GROUP BY item_id`, itemIDs, since.UTC())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: recent credits scans: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var itemID string
|
||||
var finished time.Time
|
||||
if err := rows.Scan(&itemID, &finished); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[itemID] = finished
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreditsScanHistory(ctx context.Context, limit int) ([]CreditsScanHistoryRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT h.id, h.item_id,
|
||||
coalesce(i.payload->>'Name', ''), coalesce(i.payload->>'SeriesName', ''),
|
||||
h.series_id, h.season_number, h.episode_number, h.reason, h.priority,
|
||||
h.outcome, h.marker_ms, h.confidence, h.method, h.frames_sampled,
|
||||
h.error_text, h.started_at, h.finished_at, h.duration_ms
|
||||
FROM credits_scan_history h
|
||||
LEFT JOIN library_items i ON i.id = h.item_id
|
||||
ORDER BY h.finished_at DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: credits scan history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]CreditsScanHistoryRow, 0, limit)
|
||||
for rows.Next() {
|
||||
var row CreditsScanHistoryRow
|
||||
if err := rows.Scan(&row.ID, &row.ItemID, &row.ItemName, &row.SeriesName,
|
||||
&row.SeriesID, &row.Season, &row.Episode, &row.Reason, &row.Priority,
|
||||
&row.Outcome, &row.MarkerMs, &row.Confidence, &row.Method, &row.Frames,
|
||||
&row.Error, &row.StartedAt, &row.FinishedAt, &row.DurationMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Marker queries keep the playback path cheap. Scan history is operational data on the
|
||||
// background worker path only; queue transitions remain entirely in memory.
|
||||
|
||||
// CreditsMarkerRow is one stored marker.
|
||||
type CreditsMarkerRow struct {
|
||||
|
||||
Reference in New Issue
Block a user