feat: no-access is visible per source, and findable (milestone 387 step A3)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 2m40s
Build images / build-ml (push) Successful in 2m47s
Build images / build-web (push) Successful in 1m35s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 2m40s
Build images / build-ml (push) Successful in 2m47s
Build images / build-web (push) Successful in 1m35s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
A3 of milestone 387, completing phase A. A1 made the count true, A2 made it a durable state; this makes it something the operator can see without going looking. Turned out smaller than filed, because A2 revealed why the existing `tier_limited` palette entry in FailingSourcesCard had never rendered: the chip was being cleared by the same successful run that produced it. The colour was already chosen. Where it surfaces: - SourceHealthDot gains a `no-access` grade. Deliberately its own grade rather than folded into healthy (which hides it) or warning (which sends the operator hunting for a break that isn't there). A source with real failures still grades as failing whether or not it is also gated. - SourceRow gets an info-coloured lock chip in the status cell, which was empty for these sources — they have zero failures. Placed ahead of the backfill states: "we can't see this creator" is the more useful thing to say than which walk phase it is in, and unlike those it does not resolve on its own. - A "No access" status filter, deliberately separate from "Has errors". Without it a gated source is invisible in a long list, because it correctly stays out of the failing rollup. Left OUT of NeedsAttentionCard on purpose. That card's only affordance is Retry, and you cannot retry your way into a subscription tier — issue 1285 already gives the real escape hatch, since disabling a source clears its state. Nothing structural needed changing: the card is fed by consecutive_failures > 0, which a tier-limited source never has. The count lives on the download event, not the source, so `list()` joins it in with one DISTINCT ON query — selecting the run_stats sub-object rather than whole metadata blobs, which carry up to 500KB of truncated stdout each. Scoped to tier-gated rows only, so a healthy library issues no extra query at all. Absent stays None rather than 0, and both UI surfaces phrase the state without a number when it is missing instead of printing a fabricated zero. Also covers A1's live gated count, which shipped untested, and extends the mount helper with slot stubs: SourceHealthDot puts the dot in a NAMED slot, and unresolved Vuetify components render default slots only — so those assertions would have found an empty wrapper and passed vacuously. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user