diff --git a/alembic/versions/0032_source_error_type.py b/alembic/versions/0032_source_error_type.py new file mode 100644 index 0000000..e264b35 --- /dev/null +++ b/alembic/versions/0032_source_error_type.py @@ -0,0 +1,41 @@ +"""source.error_type: surface ErrorType taxonomy in FailingSourcesCard + +Revision ID: 0032 +Revises: 0031 +Create Date: 2026-06-02 + +Audit 2026-06-02: the backend computes 13 ErrorType categories (auth_error, +rate_limited, not_found, access_denied, validation_failed, etc.) and +stamps each one on DownloadEvent.metadata, but the Source row only carried +the free-text last_error. Operators couldn't bulk-triage failing sources +("all auth_error → rotate cookies, all rate_limited → just wait") without +opening Logs per row. + +This column receives the last error_type from _update_source_health +and gets cleared on a successful run. Nullable + indexed so the failing- +sources rollup can filter/group cheaply. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0032" +down_revision: Union[str, None] = "0031" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "source", + sa.Column("error_type", sa.String(length=32), nullable=True), + ) + op.create_index( + "ix_source_error_type", "source", ["error_type"], + ) + + +def downgrade() -> None: + op.drop_index("ix_source_error_type", table_name="source") + op.drop_column("source", "error_type") diff --git a/backend/app/models/source.py b/backend/app/models/source.py index 407227e..1bf6c67 100644 --- a/backend/app/models/source.py +++ b/backend/app/models/source.py @@ -26,6 +26,11 @@ class Source(Base): last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + # alembic 0032: last ErrorType category (auth_error, rate_limited, + # not_found, ...). Lets FailingSourcesCard surface the taxonomy as + # a colored chip so operators can bulk-triage by error class. Set + # by _update_source_health alongside last_error; cleared on 'ok'. + error_type: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) check_interval_override: Mapped[int | None] = mapped_column(Integer, nullable=True) consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0) diff --git a/backend/app/services/download_service.py b/backend/app/services/download_service.py index 992a779..92b499e 100644 --- a/backend/app/services/download_service.py +++ b/backend/app/services/download_service.py @@ -434,9 +434,15 @@ class DownloadService: if status == "ok": source.consecutive_failures = 0 source.last_error = None + # alembic 0032 — clear the failure-class chip on success. + source.error_type = None elif status == "error": source.consecutive_failures = (source.consecutive_failures or 0) + 1 source.last_error = error_message + # alembic 0032 — stamp the failure-class so FailingSourcesCard + # can render a colored chip and operators can bulk-triage + # by error class without opening Logs per row. + source.error_type = error_type if error_type == "rate_limited": await set_platform_cooldown(self.async_session, source.platform) elif status == "skipped": diff --git a/backend/app/services/source_service.py b/backend/app/services/source_service.py index 1e2031c..513c9a1 100644 --- a/backend/app/services/source_service.py +++ b/backend/app/services/source_service.py @@ -59,6 +59,7 @@ class SourceRecord: config_overrides: dict | None last_checked_at: str | None last_error: str | None + error_type: str | None check_interval_override: int | None consecutive_failures: int next_check_at: str | None @@ -76,6 +77,7 @@ class SourceRecord: "config_overrides": self.config_overrides, "last_checked_at": self.last_checked_at, "last_error": self.last_error, + "error_type": self.error_type, "check_interval_override": self.check_interval_override, "consecutive_failures": self.consecutive_failures, "next_check_at": self.next_check_at, @@ -144,6 +146,7 @@ class SourceService: config_overrides=source.config_overrides, last_checked_at=source.last_checked_at.isoformat() if source.last_checked_at else None, last_error=source.last_error, + error_type=source.error_type, check_interval_override=source.check_interval_override, consecutive_failures=source.consecutive_failures or 0, next_check_at=nxt.isoformat() if nxt else None, diff --git a/frontend/src/components/subscriptions/FailingSourcesCard.vue b/frontend/src/components/subscriptions/FailingSourcesCard.vue index 21d8e3d..024a4df 100644 --- a/frontend/src/components/subscriptions/FailingSourcesCard.vue +++ b/frontend/src/components/subscriptions/FailingSourcesCard.vue @@ -25,6 +25,15 @@ {{ s.consecutive_failures }}× failed + + {{ s.error_type }} + {{ s.last_error || 'no error message recorded' }} @@ -66,6 +75,42 @@ const open = ref(true) // Per-row loading flag so the spinner lives on the row whose Logs // button was clicked, not on every row. const logLoadingIds = ref(new Set()) + +// Audit 2026-06-02: surface the ErrorType taxonomy as a colored chip +// next to the consecutive-failures count so operators can bulk-triage +// by error class. Color reflects "what to do next": +// warning (yellow) — auth/cookie issue: operator should rotate +// info (blue) — backend-paced (cooldown / rate limit / timeout) +// error (red) — likely terminal without operator intervention +const ERROR_TYPE_COLOR = { + auth_error: 'warning', + rate_limited: 'info', + timeout: 'info', + network_error: 'info', + not_found: 'error', + access_denied: 'error', + validation_failed: 'error', + unsupported_url: 'error', + http_error: 'error', + unknown_error: 'error', + partial: 'info', + tier_limited: 'info', + no_new_content: 'info', +} +const ERROR_TYPE_HINT = { + auth_error: 'Cookies likely expired — re-upload in Credentials.', + rate_limited: 'Platform-wide cooldown active. Will retry after it expires.', + timeout: 'Subprocess exceeded its time budget. Often retries cleanly.', + network_error: 'Transient network issue. Will retry on next tick.', + not_found: 'URL 404 — creator may have renamed or deleted.', + access_denied: 'Subscription tier may not grant this content.', + validation_failed: 'Downloaded files were quarantined by the validator.', + http_error: 'Generic HTTP error — see Logs.', + unsupported_url: 'gallery-dl does not support this URL pattern.', + unknown_error: 'Could not classify — see Logs.', +} +function errorTypeColor(t) { return ERROR_TYPE_COLOR[t] || 'error' } +function errorTypeHint(t) { return ERROR_TYPE_HINT[t] || '' } async function onViewLogs(s) { if (logLoadingIds.value.has(s.id)) return logLoadingIds.value = new Set(logLoadingIds.value).add(s.id) @@ -115,6 +160,7 @@ async function onViewLogs(s) { } .fc-fail__artist { font-weight: 600; white-space: nowrap; } .fc-fail__count { flex: 0 0 auto; } +.fc-fail__class { flex: 0 0 auto; } .fc-fail__err { color: rgb(var(--v-theme-on-surface-variant)); font-size: 0.8rem;