package db import ( "database/sql" "errors" "fmt" "strings" ) func (s *Store) UpsertSource(item Source) (Source, error) { now := Now() if item.SourceID == "" { item.SourceID = item.CategoryID + "-" + item.Name } existing, existingErr := s.GetSourceBySourceID(item.SourceID) hasExisting := existingErr == nil if item.Method == "" { item.Method = "GET" } item.ProxyMode = normalizeProxyMode(firstNonEmpty(item.ProxyMode, "client_direct"), item.CategoryID, item.Name, item.APIURL) if item.URLTemplate == "" { item.URLTemplate = item.APIURL } if item.TimeoutMS <= 0 { item.TimeoutMS = 8000 } if item.RetryCount <= 0 { item.RetryCount = 1 } if item.CacheSeconds <= 0 { item.CacheSeconds = item.CheckIntervalSec } if item.CacheSeconds <= 0 { item.CacheSeconds = 300 } if item.CheckIntervalSec <= 0 { item.CheckIntervalSec = item.CacheSeconds } if item.SupportedFormats == "" { if hasExisting && existing.SupportedFormats != "" { item.SupportedFormats = existing.SupportedFormats } else { item.SupportedFormats = "[]" } } if item.LastStatus == "" { if hasExisting && existing.LastStatus != "" { item.LastStatus = existing.LastStatus } else { item.LastStatus = "unknown" } } if item.LastLatencyMS == 0 && hasExisting { item.LastLatencyMS = existing.LastLatencyMS } if item.LastCheckedAt == "" && hasExisting { item.LastCheckedAt = existing.LastCheckedAt } if item.LastError == "" && hasExisting { item.LastError = existing.LastError } if item.ConsecutiveFailure == 0 && hasExisting { item.ConsecutiveFailure = existing.ConsecutiveFailure } if item.CategoryID == "" { item.CategoryID = "custom" } if item.CategoryName == "" { item.CategoryName = item.CategoryID } if !item.EnabledSet { item.Enabled = true if hasExisting { item.Enabled = existing.Enabled } } if !item.ClientVisibleSet { item.ClientVisible = true if hasExisting { item.ClientVisible = existing.ClientVisible } } if item.CreatedAt == "" && hasExisting { item.CreatedAt = existing.CreatedAt } _, _ = s.exec(`INSERT INTO source_categories (category_id, name, enabled, ui_config, created_at, updated_at) VALUES (?, ?, 1, '{}', ?, ?) ON CONFLICT (category_id) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at`, item.CategoryID, item.CategoryName, now, now) conn, d := s.active() query := d.upsert("source_endpoints", []string{"category_id", "category_name", "source_id", "name", "description", "method", "api_url", "url_template", "thumbnail_url", "proxy_mode", "timeout_ms", "retry_count", "cache_seconds", "check_interval_sec", "enabled", "client_visible", "supported_formats", "last_status", "last_latency_ms", "last_checked_at", "last_error", "consecutive_failure", "created_at", "updated_at"}, []string{"source_id"}) if _, err := conn.Exec(d.rebind(query), item.CategoryID, item.CategoryName, item.SourceID, item.Name, item.Description, item.Method, item.APIURL, item.URLTemplate, item.ThumbnailURL, item.ProxyMode, item.TimeoutMS, item.RetryCount, item.CacheSeconds, item.CheckIntervalSec, boolInt(item.Enabled), boolInt(item.ClientVisible), item.SupportedFormats, item.LastStatus, item.LastLatencyMS, item.LastCheckedAt, item.LastError, item.ConsecutiveFailure, firstNonEmpty(item.CreatedAt, now), now); err != nil { s.markFailover(err) return Source{}, err } return s.GetSourceBySourceID(item.SourceID) } func (s *Store) GetSourceBySourceID(sourceID string) (Source, error) { item, err := scanSourceRow(s.queryRow(sourceSelectSQL()+` WHERE source_id = ?`, sourceID)) if errors.Is(err, sql.ErrNoRows) { return Source{}, errors.New("source not found") } return item, err } func (s *Store) ListSources(includeHidden bool) ([]Source, error) { where := "" args := []any{} if !includeHidden { where = " WHERE enabled = 1 AND client_visible = 1" } rows, err := s.query(sourceSelectSQL()+where+` ORDER BY category_id ASC, name ASC`, args...) if err != nil { return nil, err } defer rows.Close() items := []Source{} for rows.Next() { item, err := scanSourceRowsCurrent(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (s *Store) CountSources() (int, error) { var count int err := s.queryRow(`SELECT COUNT(*) FROM source_endpoints`).Scan(&count) return count, err } func (s *Store) CountClientVisibleSources() (int, error) { var count int err := s.queryRow(`SELECT COUNT(*) FROM source_endpoints WHERE enabled = 1 AND client_visible = 1`).Scan(&count) return count, err } func (s *Store) DeleteSource(sourceID string) error { _, err := s.exec(`DELETE FROM source_endpoints WHERE source_id = ?`, sourceID) return err } func (s *Store) RecordSourceCheck(sourceDBID int64, status string, latency int, message string) error { now := Now() _, err := s.exec(`INSERT INTO endpoint_health_checks (source_db_id, status, latency_ms, error, checked_at) VALUES (?, ?, ?, ?, ?)`, sourceDBID, status, latency, sanitize(message), now) if err != nil { return err } if status == "ok" { _, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = 0, updated_at = ? WHERE id = ?`, status, latency, now, sanitize(message), now, sourceDBID) } else if status == "redirected" { _, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = 0, updated_at = ? WHERE id = ?`, status, latency, now, sanitize(message), now, sourceDBID) } else { _, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = consecutive_failure + 1, updated_at = ? WHERE id = ?`, status, latency, now, sanitize(message), now, sourceDBID) } return err } func (s *Store) SourceHealthHistory(sourceDBIDs []int64, limit int) (map[int64][]map[string]any, error) { if len(sourceDBIDs) == 0 { return map[int64][]map[string]any{}, nil } if limit <= 0 || limit > 48 { limit = 16 } placeholders := make([]string, 0, len(sourceDBIDs)) args := make([]any, 0, len(sourceDBIDs)+1) for _, id := range sourceDBIDs { if id <= 0 { continue } placeholders = append(placeholders, "?") args = append(args, id) } if len(placeholders) == 0 { return map[int64][]map[string]any{}, nil } args = append(args, limit*len(placeholders)) rows, err := s.query(fmt.Sprintf(`SELECT source_db_id, status, latency_ms, checked_at FROM endpoint_health_checks WHERE source_db_id IN (%s) ORDER BY checked_at DESC, id DESC LIMIT ?`, strings.Join(placeholders, ",")), args...) if err != nil { return nil, err } defer rows.Close() out := map[int64][]map[string]any{} for rows.Next() { var sourceDBID int64 var status, checkedAt string var latency int if err := rows.Scan(&sourceDBID, &status, &latency, &checkedAt); err != nil { return nil, err } if len(out[sourceDBID]) >= limit { continue } out[sourceDBID] = append(out[sourceDBID], map[string]any{ "status": status, "latencyMs": latency, "latency_ms": latency, "checkedAt": checkedAt, "checked_at": checkedAt, }) } if err := rows.Err(); err != nil { return nil, err } for id, items := range out { for left, right := 0, len(items)-1; left < right; left, right = left+1, right-1 { items[left], items[right] = items[right], items[left] } out[id] = items } return out, nil } func (s *Store) RecordSourceCall(call SourceCall) error { if call.CreatedAt == "" { call.CreatedAt = Now() } _, err := s.exec(`INSERT INTO endpoint_call_logs (source_id, status, latency_ms, error, client, created_at) VALUES (?, ?, ?, ?, ?, ?)`, sanitize(call.SourceID), sanitize(call.Status), call.LatencyMS, sanitize(call.Error), sanitize(call.Client), call.CreatedAt) return err }