diff --git a/backend/app/services/source_service.py b/backend/app/services/source_service.py index dcc66d5..e6b22c0 100644 --- a/backend/app/services/source_service.py +++ b/backend/app/services/source_service.py @@ -10,12 +10,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..models import ( Artist, + DownloadEvent, ImageProvenance, ImageRecord, ImportSettings, Post, Source, ) +from .gallery_dl import ErrorType from .platforms import known_platform_keys from .scheduler_service import compute_next_check_at @@ -84,6 +86,11 @@ class SourceRecord: # plan #704: cumulative posts processed across the walk's chunks — live # progress for the badge. backfill_posts: int + # Milestone #387 A3: posts the last walk skipped because the account can't + # view them. Lives on the EVENT (run_stats.tier_gated_count), not the + # source, so it is joined in by `list()` only — None everywhere else, which + # the UI renders as the bare no-access state with no fabricated number. + tier_gated_count: int | None = None def to_dict(self) -> dict: return { @@ -107,6 +114,7 @@ class SourceRecord: "backfill_bypass_seen": self.backfill_bypass_seen, "backfill_recapture": self.backfill_recapture, "backfill_posts": self.backfill_posts, + "tier_gated_count": self.tier_gated_count, } @@ -159,8 +167,39 @@ class SourceService: async def _load_settings(self) -> ImportSettings: return await ImportSettings.load(self.session) + async def _tier_gated_counts(self, source_ids: list[int]) -> dict[int, int]: + """Latest walk's tier-gated post count, per source, in ONE query. + + Selects the `run_stats` sub-object rather than whole `metadata` blobs: + those carry truncated stdout/stderr up to 500KB each, and pulling one + per source to read a single integer would make the subscriptions list + pay for the Logs view. DISTINCT ON + ORDER BY takes the newest event per + source (Postgres-only, like the rest of this codebase). + + Callers pass only the sources that actually need it — the count is + meaningless for a source that isn't tier-gated. + """ + if not source_ids: + return {} + rows = (await self.session.execute( + select( + DownloadEvent.source_id, + DownloadEvent.metadata_["run_stats"], + ) + .where(DownloadEvent.source_id.in_(source_ids)) + .distinct(DownloadEvent.source_id) + .order_by(DownloadEvent.source_id, DownloadEvent.started_at.desc()) + )).all() + counts: dict[int, int] = {} + for source_id, run_stats in rows: + n = (run_stats or {}).get("tier_gated_count") or 0 + if n: + counts[source_id] = int(n) + return counts + def _build_record( self, source: Source, artist: Artist, settings: ImportSettings, + gated_counts: dict[int, int] | None = None, ) -> SourceRecord: nxt = compute_next_check_at(source, artist, settings) co = source.config_overrides or {} @@ -185,6 +224,7 @@ class SourceService: backfill_bypass_seen=bool(co.get("_backfill_bypass_seen")), backfill_recapture=bool(co.get("_backfill_recapture")), backfill_posts=int(co.get("_backfill_posts", 0)), + tier_gated_count=(gated_counts or {}).get(source.id), ) async def _row_to_record(self, source: Source) -> SourceRecord: @@ -217,7 +257,12 @@ class SourceService: stmt = stmt.order_by(Artist.name.asc(), Source.id.asc()) rows = (await self.session.execute(stmt)).all() settings = await self._load_settings() - return [self._build_record(s, a, settings) for s, a in rows] + # Only tier-gated rows need the join — on a healthy library that is an + # empty list and _tier_gated_counts short-circuits without a query. + gated_counts = await self._tier_gated_counts( + [s.id for s, _a in rows if s.error_type == ErrorType.TIER_LIMITED] + ) + return [self._build_record(s, a, settings, gated_counts) for s, a in rows] async def get(self, source_id: int) -> SourceRecord | None: source = (await self.session.execute( diff --git a/frontend/src/components/subscriptions/SourceHealthDot.vue b/frontend/src/components/subscriptions/SourceHealthDot.vue index 145044d..b84b7db 100644 --- a/frontend/src/components/subscriptions/SourceHealthDot.vue +++ b/frontend/src/components/subscriptions/SourceHealthDot.vue @@ -10,6 +10,7 @@
Last checked: {{ lastCheckedText }}
Next check: {{ nextCheckText }}
+
{{ noAccessText }}
Failures: {{ source.consecutive_failures }}
@@ -29,14 +30,29 @@ const props = defineProps({ warningThreshold: { type: Number, default: 5 }, }) +const noAccess = computed(() => props.source.error_type === 'tier_limited') + const level = computed(() => { if (!props.source.last_checked_at) return 'unchecked' const f = props.source.consecutive_failures || 0 - if (f === 0) return 'healthy' + // No-access outranks 'healthy' but is NOT a failure grade: the walk worked, + // the content simply isn't ours. Checked after failures so a source that is + // genuinely erroring still reads as erroring. + if (f === 0) return noAccess.value ? 'no-access' : 'healthy' if (f < props.warningThreshold) return 'warning' return 'critical' }) +// The count comes from the last walk's run_stats and is only joined in by the +// list endpoint, so it can legitimately be absent — say the state without it +// rather than printing a fabricated zero. +const noAccessText = computed(() => { + const n = props.source.tier_gated_count + return n + ? `${n} post${n === 1 ? '' : 's'} you don't have access to` + : "Some posts are behind a tier you don't hold" +}) + const ariaLabel = computed(() => `source health: ${level.value}`) const lastCheckedText = computed(() => formatRelative(props.source.last_checked_at)) @@ -63,6 +79,9 @@ const truncatedError = computed(() => { } .fc-health-dot--unchecked { background-color: rgb(var(--v-theme-on-surface-variant)); opacity: 0.5; } .fc-health-dot--healthy { background-color: rgb(var(--v-theme-success, 76 175 80)); } +/* Matches the 'info' severity FailingSourcesCard already assigns tier_limited — + deliberately not a warning/error hue: nothing is broken. */ +.fc-health-dot--no-access { background-color: rgb(var(--v-theme-info, 33 150 243)); } .fc-health-dot--warning { background-color: rgb(var(--v-theme-warning, 255 167 38)); } .fc-health-dot--critical { background-color: rgb(var(--v-theme-error, 244 67 54)); } @@ -70,6 +89,9 @@ const truncatedError = computed(() => { font-size: 0.85rem; line-height: 1.4; } +.fc-health-tip__gated { + color: rgb(var(--v-theme-info, 33 150 243)); +} .fc-health-tip__err { margin-top: 0.25rem; color: rgb(var(--v-theme-error, 244 67 54)); diff --git a/frontend/src/components/subscriptions/SourceRow.vue b/frontend/src/components/subscriptions/SourceRow.vue index 10de1b0..0c287a0 100644 --- a/frontend/src/components/subscriptions/SourceRow.vue +++ b/frontend/src/components/subscriptions/SourceRow.vue @@ -48,6 +48,20 @@ {{ source.last_error }} + + {{ source.tier_gated_count ? `${source.tier_gated_count} gated` : 'No access' }} + + {{ noAccessTip }} + +