601 lines
19 KiB
Go
601 lines
19 KiB
Go
package metadata
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
var ErrRefreshDebtNotFound = errors.New("metadata refresh debt not found")
|
|
|
|
const metadataRefreshDebtColumns = `target_type, content_id, priority, reason_mask, next_refresh_at,
|
|
claimed_at, lease_expires_at, last_attempt_at, last_success_at, attempt_count, last_error, updated_at`
|
|
|
|
const metadataRefreshDebtReturningColumns = `d.target_type, d.content_id, d.priority, d.reason_mask, d.next_refresh_at,
|
|
d.claimed_at, d.lease_expires_at, d.last_attempt_at, d.last_success_at, d.attempt_count, d.last_error, d.updated_at`
|
|
|
|
const metadataRefreshDebtLeaseDuration = 15 * time.Minute
|
|
|
|
const metadataRefreshDebtEnabledAccessPredicate = `(
|
|
(d.target_type = 'item' AND EXISTS (
|
|
SELECT 1
|
|
FROM media_item_libraries mil
|
|
JOIN media_folders folders ON folders.id = mil.media_folder_id
|
|
WHERE mil.content_id = d.content_id
|
|
AND folders.enabled = TRUE
|
|
))
|
|
OR (d.target_type = 'season' AND EXISTS (
|
|
SELECT 1
|
|
FROM seasons s
|
|
JOIN media_item_libraries mil ON mil.content_id = s.series_id
|
|
JOIN media_folders folders ON folders.id = mil.media_folder_id
|
|
WHERE s.content_id = d.content_id
|
|
AND folders.enabled = TRUE
|
|
))
|
|
OR (d.target_type = 'episode' AND EXISTS (
|
|
SELECT 1
|
|
FROM episode_libraries el
|
|
JOIN media_folders folders ON folders.id = el.media_folder_id
|
|
WHERE el.episode_id = d.content_id
|
|
AND folders.enabled = TRUE
|
|
))
|
|
)`
|
|
|
|
type RefreshDebtRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewRefreshDebtRepository(pool *pgxpool.Pool) *RefreshDebtRepository {
|
|
return &RefreshDebtRepository{pool: pool}
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) requireConfigured() error {
|
|
if r == nil || r.pool == nil {
|
|
return errors.New("metadata refresh debt repository is not configured")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scanRefreshDebt(row pgx.Row) (*models.MetadataRefreshDebt, error) {
|
|
var debt models.MetadataRefreshDebt
|
|
if err := row.Scan(
|
|
&debt.TargetType,
|
|
&debt.ContentID,
|
|
&debt.Priority,
|
|
&debt.ReasonMask,
|
|
&debt.NextRefreshAt,
|
|
&debt.ClaimedAt,
|
|
&debt.LeaseExpiresAt,
|
|
&debt.LastAttemptAt,
|
|
&debt.LastSuccessAt,
|
|
&debt.AttemptCount,
|
|
&debt.LastError,
|
|
&debt.UpdatedAt,
|
|
); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrRefreshDebtNotFound
|
|
}
|
|
return nil, fmt.Errorf("scanning metadata refresh debt: %w", err)
|
|
}
|
|
return &debt, nil
|
|
}
|
|
|
|
func scanRefreshDebts(rows pgx.Rows) ([]*models.MetadataRefreshDebt, error) {
|
|
var debts []*models.MetadataRefreshDebt
|
|
for rows.Next() {
|
|
debt, err := scanRefreshDebt(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
debts = append(debts, debt)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating metadata refresh debt rows: %w", err)
|
|
}
|
|
if debts == nil {
|
|
debts = []*models.MetadataRefreshDebt{}
|
|
}
|
|
return debts, nil
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) Get(ctx context.Context, contentID string) (*models.MetadataRefreshDebt, error) {
|
|
return r.GetTarget(ctx, RefreshTargetItem, contentID)
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) GetTarget(ctx context.Context, targetType, contentID string) (*models.MetadataRefreshDebt, error) {
|
|
if err := r.requireConfigured(); err != nil {
|
|
return nil, err
|
|
}
|
|
targetType = NormalizeRefreshTargetType(targetType)
|
|
contentID = strings.TrimSpace(contentID)
|
|
if targetType == "" || contentID == "" {
|
|
return nil, ErrRefreshDebtNotFound
|
|
}
|
|
|
|
return scanRefreshDebt(r.pool.QueryRow(ctx, `
|
|
SELECT `+metadataRefreshDebtColumns+`
|
|
FROM metadata_refresh_debt
|
|
WHERE target_type = $1 AND content_id = $2
|
|
`, targetType, contentID))
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) UpsertDebt(ctx context.Context, contentID string, priority int, reasonMask int64, nextRefreshAt time.Time) error {
|
|
return r.UpsertTargetDebt(ctx, RefreshTargetItem, contentID, priority, reasonMask, nextRefreshAt)
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) UpsertTargetDebt(ctx context.Context, targetType, contentID string, priority int, reasonMask int64, nextRefreshAt time.Time) error {
|
|
if err := r.requireConfigured(); err != nil {
|
|
return err
|
|
}
|
|
targetType = NormalizeRefreshTargetType(targetType)
|
|
contentID = strings.TrimSpace(contentID)
|
|
if targetType == "" || contentID == "" || reasonMask == 0 {
|
|
return nil
|
|
}
|
|
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO metadata_refresh_debt (
|
|
target_type,
|
|
content_id,
|
|
priority,
|
|
reason_mask,
|
|
next_refresh_at,
|
|
updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, NOW())
|
|
ON CONFLICT (target_type, content_id) DO UPDATE SET
|
|
priority = GREATEST(metadata_refresh_debt.priority, EXCLUDED.priority),
|
|
reason_mask = metadata_refresh_debt.reason_mask | EXCLUDED.reason_mask,
|
|
next_refresh_at = LEAST(metadata_refresh_debt.next_refresh_at, EXCLUDED.next_refresh_at),
|
|
updated_at = NOW()
|
|
`, targetType, contentID, priority, reasonMask, nextRefreshAt.UTC())
|
|
if err != nil {
|
|
return fmt.Errorf("upserting metadata refresh debt: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) RequestDue(
|
|
ctx context.Context,
|
|
targetType string,
|
|
contentID string,
|
|
priority int,
|
|
reasonMask int64,
|
|
nextRefreshAt time.Time,
|
|
cooldown time.Duration,
|
|
) error {
|
|
if err := r.requireConfigured(); err != nil {
|
|
return err
|
|
}
|
|
targetType = NormalizeRefreshTargetType(targetType)
|
|
contentID = strings.TrimSpace(contentID)
|
|
if targetType == "" || contentID == "" || reasonMask == 0 {
|
|
return nil
|
|
}
|
|
if cooldown < 0 {
|
|
cooldown = 0
|
|
}
|
|
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO metadata_refresh_debt (
|
|
target_type,
|
|
content_id,
|
|
priority,
|
|
reason_mask,
|
|
next_refresh_at,
|
|
updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, NOW())
|
|
ON CONFLICT (target_type, content_id) DO UPDATE SET
|
|
priority = GREATEST(metadata_refresh_debt.priority, EXCLUDED.priority),
|
|
reason_mask = metadata_refresh_debt.reason_mask | EXCLUDED.reason_mask,
|
|
next_refresh_at = CASE
|
|
WHEN metadata_refresh_debt.lease_expires_at IS NOT NULL
|
|
AND metadata_refresh_debt.lease_expires_at >= NOW()
|
|
THEN metadata_refresh_debt.next_refresh_at
|
|
WHEN metadata_refresh_debt.last_attempt_at IS NOT NULL
|
|
AND metadata_refresh_debt.last_attempt_at > NOW() - $6::interval
|
|
THEN metadata_refresh_debt.next_refresh_at
|
|
WHEN metadata_refresh_debt.last_success_at IS NOT NULL
|
|
AND metadata_refresh_debt.last_success_at > NOW() - $6::interval
|
|
THEN metadata_refresh_debt.next_refresh_at
|
|
ELSE LEAST(metadata_refresh_debt.next_refresh_at, EXCLUDED.next_refresh_at)
|
|
END,
|
|
updated_at = NOW()
|
|
`, targetType, contentID, priority, reasonMask, nextRefreshAt.UTC(), intervalLiteral(cooldown))
|
|
if err != nil {
|
|
return fmt.Errorf("requesting due metadata refresh debt: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) ClaimDue(ctx context.Context, limit int) ([]*models.MetadataRefreshDebt, error) {
|
|
if err := r.requireConfigured(); err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 {
|
|
return []*models.MetadataRefreshDebt{}, nil
|
|
}
|
|
|
|
rows, err := r.pool.Query(ctx, `
|
|
WITH due AS (
|
|
SELECT d.target_type, d.content_id
|
|
FROM metadata_refresh_debt d
|
|
WHERE d.next_refresh_at <= NOW()
|
|
AND (d.lease_expires_at IS NULL OR d.lease_expires_at < NOW())
|
|
AND `+metadataRefreshDebtEnabledAccessPredicate+`
|
|
ORDER BY d.priority DESC, d.next_refresh_at ASC, d.updated_at ASC
|
|
LIMIT $1
|
|
FOR UPDATE SKIP LOCKED
|
|
)
|
|
UPDATE metadata_refresh_debt d
|
|
SET claimed_at = NOW(),
|
|
lease_expires_at = NOW() + $2::interval,
|
|
last_attempt_at = NOW(),
|
|
attempt_count = d.attempt_count + 1,
|
|
updated_at = NOW()
|
|
FROM due
|
|
WHERE d.target_type = due.target_type
|
|
AND d.content_id = due.content_id
|
|
RETURNING `+metadataRefreshDebtReturningColumns, limit, intervalLiteral(metadataRefreshDebtLeaseDuration))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claiming due metadata refresh debt: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanRefreshDebts(rows)
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) PruneDisabledLibraryDebt(ctx context.Context) error {
|
|
if err := r.requireConfigured(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := r.pool.Exec(ctx, `
|
|
DELETE FROM metadata_refresh_debt d
|
|
WHERE NOT `+metadataRefreshDebtEnabledAccessPredicate+`
|
|
`); err != nil {
|
|
return fmt.Errorf("pruning disabled-library metadata refresh debt: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) DeleteDebt(ctx context.Context, contentID string) error {
|
|
return r.DeleteTargetDebt(ctx, RefreshTargetItem, contentID)
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) DeleteTargetDebt(ctx context.Context, targetType, contentID string) error {
|
|
if err := r.requireConfigured(); err != nil {
|
|
return err
|
|
}
|
|
targetType = NormalizeRefreshTargetType(targetType)
|
|
contentID = strings.TrimSpace(contentID)
|
|
if targetType == "" || contentID == "" {
|
|
return nil
|
|
}
|
|
|
|
_, err := r.pool.Exec(ctx, `
|
|
DELETE FROM metadata_refresh_debt
|
|
WHERE target_type = $1 AND content_id = $2
|
|
`, targetType, contentID)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting metadata refresh debt: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) MarkFailure(
|
|
ctx context.Context,
|
|
contentID string,
|
|
priority int,
|
|
reasonMask int64,
|
|
nextRefreshAt time.Time,
|
|
attemptCount int,
|
|
lastError string,
|
|
) error {
|
|
return r.MarkTargetFailure(ctx, RefreshTargetItem, contentID, priority, reasonMask, nextRefreshAt, attemptCount, lastError)
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) MarkTargetFailure(
|
|
ctx context.Context,
|
|
targetType string,
|
|
contentID string,
|
|
priority int,
|
|
reasonMask int64,
|
|
nextRefreshAt time.Time,
|
|
attemptCount int,
|
|
lastError string,
|
|
) error {
|
|
if err := r.requireConfigured(); err != nil {
|
|
return err
|
|
}
|
|
targetType = NormalizeRefreshTargetType(targetType)
|
|
contentID = strings.TrimSpace(contentID)
|
|
if targetType == "" || contentID == "" || reasonMask == 0 {
|
|
return nil
|
|
}
|
|
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO metadata_refresh_debt (
|
|
target_type,
|
|
content_id,
|
|
priority,
|
|
reason_mask,
|
|
next_refresh_at,
|
|
claimed_at,
|
|
lease_expires_at,
|
|
last_attempt_at,
|
|
attempt_count,
|
|
last_error,
|
|
updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, NULL, NULL, NOW(), $6, $7, NOW())
|
|
ON CONFLICT (target_type, content_id) DO UPDATE SET
|
|
priority = EXCLUDED.priority,
|
|
reason_mask = EXCLUDED.reason_mask,
|
|
next_refresh_at = EXCLUDED.next_refresh_at,
|
|
claimed_at = NULL,
|
|
lease_expires_at = NULL,
|
|
last_attempt_at = NOW(),
|
|
attempt_count = GREATEST(metadata_refresh_debt.attempt_count, EXCLUDED.attempt_count),
|
|
last_error = EXCLUDED.last_error,
|
|
updated_at = NOW()
|
|
`, targetType, contentID, priority, reasonMask, nextRefreshAt.UTC(), attemptCount, strings.TrimSpace(lastError))
|
|
if err != nil {
|
|
return fmt.Errorf("marking metadata refresh debt failure: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) MarkSuccess(
|
|
ctx context.Context,
|
|
contentID string,
|
|
priority int,
|
|
reasonMask int64,
|
|
nextRefreshAt time.Time,
|
|
) error {
|
|
return r.MarkTargetSuccess(ctx, RefreshTargetItem, contentID, priority, reasonMask, nextRefreshAt)
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) MarkTargetSuccess(
|
|
ctx context.Context,
|
|
targetType string,
|
|
contentID string,
|
|
priority int,
|
|
reasonMask int64,
|
|
nextRefreshAt time.Time,
|
|
) error {
|
|
if err := r.requireConfigured(); err != nil {
|
|
return err
|
|
}
|
|
targetType = NormalizeRefreshTargetType(targetType)
|
|
contentID = strings.TrimSpace(contentID)
|
|
if targetType == "" || contentID == "" {
|
|
return nil
|
|
}
|
|
if reasonMask == 0 {
|
|
return r.DeleteTargetDebt(ctx, targetType, contentID)
|
|
}
|
|
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO metadata_refresh_debt (
|
|
target_type,
|
|
content_id,
|
|
priority,
|
|
reason_mask,
|
|
next_refresh_at,
|
|
claimed_at,
|
|
lease_expires_at,
|
|
last_success_at,
|
|
last_error,
|
|
updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, NULL, NULL, NOW(), '', NOW())
|
|
ON CONFLICT (target_type, content_id) DO UPDATE SET
|
|
priority = EXCLUDED.priority,
|
|
reason_mask = EXCLUDED.reason_mask,
|
|
next_refresh_at = EXCLUDED.next_refresh_at,
|
|
claimed_at = NULL,
|
|
lease_expires_at = NULL,
|
|
last_success_at = NOW(),
|
|
last_error = '',
|
|
updated_at = NOW()
|
|
`, targetType, contentID, priority, reasonMask, nextRefreshAt.UTC())
|
|
if err != nil {
|
|
return fmt.Errorf("marking metadata refresh debt success: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) GetMetrics(ctx context.Context, sampleLimit int) (*models.MetadataRefreshMetrics, error) {
|
|
if err := r.requireConfigured(); err != nil {
|
|
return nil, err
|
|
}
|
|
if sampleLimit <= 0 {
|
|
sampleLimit = 10
|
|
}
|
|
|
|
metrics := &models.MetadataRefreshMetrics{
|
|
ReasonCounts: []models.MetadataRefreshReasonCount{},
|
|
AttemptBuckets: []models.MetadataRefreshAttemptBucket{},
|
|
DueSamples: []models.MetadataRefreshDebtSample{},
|
|
RecentErrors: []models.MetadataRefreshDebtSample{},
|
|
}
|
|
|
|
if err := r.pool.QueryRow(ctx, `
|
|
SELECT
|
|
COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE next_refresh_at <= NOW()) AS due,
|
|
COUNT(*) FILTER (WHERE lease_expires_at IS NOT NULL AND lease_expires_at >= NOW()) AS leased,
|
|
MIN(next_refresh_at) FILTER (WHERE next_refresh_at <= NOW()) AS oldest_due_at,
|
|
MIN(lease_expires_at) FILTER (WHERE lease_expires_at IS NOT NULL AND lease_expires_at >= NOW()) AS oldest_lease_expires_at
|
|
FROM metadata_refresh_debt
|
|
`).Scan(
|
|
&metrics.Total,
|
|
&metrics.Due,
|
|
&metrics.Leased,
|
|
&metrics.OldestDueAt,
|
|
&metrics.OldestLeaseExpiresAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("querying metadata refresh metrics summary: %w", err)
|
|
}
|
|
|
|
reasonDefs := []struct {
|
|
reason string
|
|
mask int64
|
|
}{
|
|
{reason: "episode_incomplete", mask: RefreshDebtReasonEpisodeIncomplete},
|
|
{reason: "stale_provider_id", mask: RefreshDebtReasonStaleProviderID},
|
|
{reason: "provider_id_incomplete", mask: RefreshDebtReasonProviderIDIncomplete},
|
|
{reason: "refresh_failure", mask: RefreshDebtReasonRefreshFailure},
|
|
{reason: "core_metadata_incomplete", mask: RefreshDebtReasonCoreMetadataIncomplete},
|
|
}
|
|
for _, def := range reasonDefs {
|
|
var count int
|
|
if err := r.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*)
|
|
FROM metadata_refresh_debt
|
|
WHERE (reason_mask & $1) <> 0
|
|
`, def.mask).Scan(&count); err != nil {
|
|
return nil, fmt.Errorf("querying metadata refresh reason count for %s: %w", def.reason, err)
|
|
}
|
|
metrics.ReasonCounts = append(metrics.ReasonCounts, models.MetadataRefreshReasonCount{
|
|
Reason: def.reason,
|
|
Count: count,
|
|
})
|
|
}
|
|
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT label, count
|
|
FROM (
|
|
SELECT '0' AS label, COUNT(*) FILTER (WHERE attempt_count = 0) AS count FROM metadata_refresh_debt
|
|
UNION ALL
|
|
SELECT '1' AS label, COUNT(*) FILTER (WHERE attempt_count = 1) AS count FROM metadata_refresh_debt
|
|
UNION ALL
|
|
SELECT '2-3' AS label, COUNT(*) FILTER (WHERE attempt_count BETWEEN 2 AND 3) AS count FROM metadata_refresh_debt
|
|
UNION ALL
|
|
SELECT '4-7' AS label, COUNT(*) FILTER (WHERE attempt_count BETWEEN 4 AND 7) AS count FROM metadata_refresh_debt
|
|
UNION ALL
|
|
SELECT '8+' AS label, COUNT(*) FILTER (WHERE attempt_count >= 8) AS count FROM metadata_refresh_debt
|
|
) buckets
|
|
ORDER BY label
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying metadata refresh attempt buckets: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var bucket models.MetadataRefreshAttemptBucket
|
|
if err := rows.Scan(&bucket.Label, &bucket.Count); err != nil {
|
|
return nil, fmt.Errorf("scanning metadata refresh attempt bucket: %w", err)
|
|
}
|
|
metrics.AttemptBuckets = append(metrics.AttemptBuckets, bucket)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating metadata refresh attempt buckets: %w", err)
|
|
}
|
|
|
|
dueSamples, err := r.querySamples(ctx, `
|
|
SELECT
|
|
d.target_type,
|
|
d.content_id,
|
|
COALESCE(CASE d.target_type
|
|
WHEN 'item' THEN COALESCE(mi.title, '')
|
|
WHEN 'season' THEN TRIM(BOTH ' ' FROM COALESCE(smi.title || ' - ', '') || COALESCE(NULLIF(s.title, ''), 'Season ' || s.season_number::text))
|
|
WHEN 'episode' THEN TRIM(BOTH ' ' FROM COALESCE(emi.title || ' - ', '') || 'S' || e.season_number::text || 'E' || e.episode_number::text || ' ' || COALESCE(NULLIF(e.title, ''), ''))
|
|
ELSE ''
|
|
END, '') AS title,
|
|
CASE d.target_type
|
|
WHEN 'item' THEN COALESCE(mi.type, '')
|
|
ELSE d.target_type
|
|
END AS type,
|
|
d.reason_mask,
|
|
d.next_refresh_at,
|
|
d.last_attempt_at,
|
|
d.attempt_count,
|
|
d.last_error
|
|
FROM metadata_refresh_debt d
|
|
LEFT JOIN media_items mi ON d.target_type = 'item' AND mi.content_id = d.content_id
|
|
LEFT JOIN seasons s ON d.target_type = 'season' AND s.content_id = d.content_id
|
|
LEFT JOIN media_items smi ON smi.content_id = s.series_id
|
|
LEFT JOIN episodes e ON d.target_type = 'episode' AND e.content_id = d.content_id
|
|
LEFT JOIN media_items emi ON emi.content_id = e.series_id
|
|
WHERE d.next_refresh_at <= NOW()
|
|
ORDER BY d.priority DESC, d.next_refresh_at ASC, d.updated_at ASC
|
|
LIMIT $1
|
|
`, sampleLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
metrics.DueSamples = dueSamples
|
|
|
|
recentErrors, err := r.querySamples(ctx, `
|
|
SELECT
|
|
d.target_type,
|
|
d.content_id,
|
|
COALESCE(CASE d.target_type
|
|
WHEN 'item' THEN COALESCE(mi.title, '')
|
|
WHEN 'season' THEN TRIM(BOTH ' ' FROM COALESCE(smi.title || ' - ', '') || COALESCE(NULLIF(s.title, ''), 'Season ' || s.season_number::text))
|
|
WHEN 'episode' THEN TRIM(BOTH ' ' FROM COALESCE(emi.title || ' - ', '') || 'S' || e.season_number::text || 'E' || e.episode_number::text || ' ' || COALESCE(NULLIF(e.title, ''), ''))
|
|
ELSE ''
|
|
END, '') AS title,
|
|
CASE d.target_type
|
|
WHEN 'item' THEN COALESCE(mi.type, '')
|
|
ELSE d.target_type
|
|
END AS type,
|
|
d.reason_mask,
|
|
d.next_refresh_at,
|
|
d.last_attempt_at,
|
|
d.attempt_count,
|
|
d.last_error
|
|
FROM metadata_refresh_debt d
|
|
LEFT JOIN media_items mi ON d.target_type = 'item' AND mi.content_id = d.content_id
|
|
LEFT JOIN seasons s ON d.target_type = 'season' AND s.content_id = d.content_id
|
|
LEFT JOIN media_items smi ON smi.content_id = s.series_id
|
|
LEFT JOIN episodes e ON d.target_type = 'episode' AND e.content_id = d.content_id
|
|
LEFT JOIN media_items emi ON emi.content_id = e.series_id
|
|
WHERE COALESCE(d.last_error, '') <> ''
|
|
ORDER BY d.updated_at DESC
|
|
LIMIT $1
|
|
`, sampleLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
metrics.RecentErrors = recentErrors
|
|
|
|
return metrics, nil
|
|
}
|
|
|
|
func (r *RefreshDebtRepository) querySamples(ctx context.Context, query string, limit int) ([]models.MetadataRefreshDebtSample, error) {
|
|
rows, err := r.pool.Query(ctx, query, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying metadata refresh debt samples: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
samples := make([]models.MetadataRefreshDebtSample, 0, limit)
|
|
for rows.Next() {
|
|
var sample models.MetadataRefreshDebtSample
|
|
if err := rows.Scan(
|
|
&sample.TargetType,
|
|
&sample.ContentID,
|
|
&sample.Title,
|
|
&sample.Type,
|
|
&sample.ReasonMask,
|
|
&sample.NextRefreshAt,
|
|
&sample.LastAttemptAt,
|
|
&sample.AttemptCount,
|
|
&sample.LastError,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning metadata refresh debt sample: %w", err)
|
|
}
|
|
samples = append(samples, sample)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating metadata refresh debt samples: %w", err)
|
|
}
|
|
return samples, nil
|
|
}
|