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 @@