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;