feat: what you pay for, against what FC actually follows (387 C4)
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m18s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m11s
Build images / promote (push) Skipped
CI / integration (push) Successful in 3m4s

Reconciliation in Subscriptions, asymmetric on purpose. Subscriptions FC does not follow get a per-row add; sources the roster cannot account for are REPORT ONLY (operator decision) and link to the list on the same page. No one-click disable, so no disabled-reason column and no migration.

The membership<->source join lands as a SHARED resolver in membership_roster, not inline here: E4 now uses it as its negative check, so the two features cannot give different answers to 'is this membership already tracked?'. Keys on the exact cached campaign id FIRST and the URL handle only as fallback, because the id is written only after a source has been walked once. Read via any <platform>_campaign_id override rather than naming Patreon's, per rule 169.

The report-only bucket is gated on roster freshness and carries a per-row basis, so 'your membership says former patron', 'we know this id and it is absent', and 'we only have a handle' stay three different sentences. has_paid_access None never reads as lapsed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
2026-09-11 21:38:03 -04:00
co-authored by Claude Opus 5
parent f8614d437d
commit fc136006b7
9 changed files with 954 additions and 2 deletions
+94 -1
View File
@@ -34,7 +34,7 @@ from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import MembershipSync, PlatformMembership
from ..models import MembershipSync, PlatformMembership, Source
log = logging.getLogger(__name__)
@@ -275,3 +275,96 @@ async def sync_platform(
await session.commit()
log.info("membership sync ok for %s: %d membership(s)", platform, len(memberships))
return {"platform": platform, "ok": True, "count": len(memberships)}
# ---------------------------------------------------------------------------
# Membership <-> Source identity (#387 C4)
# ---------------------------------------------------------------------------
#
# "Is this membership already tracked?" is asked by TWO features — C4's
# reconciliation buckets and E4's creator suggestions — and it lives here, once,
# on purpose. Built inline in C4 it would have looked finished while leaving E4
# matching on name similarity alone, so the two would answer the same question
# differently and only one of them would be right.
#
# E4 and C4 use it from opposite sides: E4 as the NEGATIVE check (propose only
# where nothing matches) and C4 as the join itself.
# Any platform that caches its creator id does so under this suffix; see
# `download_service._phase3_persist`, which writes `patreon_campaign_id`.
_CAMPAIGN_KEY_SUFFIX = "_campaign_id"
def identity_keys_for_source(source: Source) -> set[str]:
"""Every platform-side creator id cached on this source.
Reads ANY `<platform>_campaign_id` override rather than naming Patreon's,
so a second platform participates by caching its id under the same suffix —
no registry, no `if platform ==` branch (rule 169). A source that has never
been walked has cached nothing and simply contributes no exact key, which is
what makes the handle fallback below necessary rather than merely tolerated.
"""
keys = set()
for name, value in (source.config_overrides or {}).items():
if name.endswith(_CAMPAIGN_KEY_SUFFIX) and isinstance(value, str) and value:
keys.add(value)
return keys
def url_tail(url: str | None) -> str | None:
"""The creator handle at the end of a source URL, lowercased.
Deliberately the same derivation as `PlatformMembership.vanity_or_none`'s
own fallback, so both sides of the comparison reduce a URL to a handle the
same way. Query strings and fragments are stripped first; Patreon's `/c/`
and `/cw/` forms both end in the vanity, so they need no special case (the
missing-`/c/` regex is what broke creator detection in #1485).
Returns None for the pre-0030 `sidecar:` synthetic anchors, which are not
feeds and must never match anything.
"""
if not url or url.startswith("sidecar:"):
return None
cleaned = url.split("?", 1)[0].split("#", 1)[0]
tail = cleaned.rstrip("/").rsplit("/", 1)[-1]
return tail.lower() or None
def match_kind(source: Source, membership: PlatformMembership) -> str | None:
"""How this source and this membership are known to be the same creator.
Returns "campaign" for an exact platform-id match, "vanity" for agreeing URL
handles, or None for no evidence.
THE ORDER MUST NOT BE INVERTED. The campaign id is exact and the handle is
not, but the id is only written AFTER a source has been walked at least once
— so checking the handle first would let a stale or renamed URL outvote the
authoritative id on every source FC has actually polled.
"""
if source.platform != membership.platform:
return None
if membership.external_campaign_id in identity_keys_for_source(source):
return "campaign"
vanity = membership.vanity_or_none()
tail = url_tail(source.url)
if vanity and tail and vanity.strip().lower() == tail:
return "vanity"
return None
async def source_for_membership(
session: AsyncSession, membership: PlatformMembership,
) -> Source | None:
"""The source FC already tracks for this membership, if there is one.
Scoped to the membership's own platform, so a creator tracked on Discord and
subscribed to on Patreon does not read as already-tracked — that pairing is
E4's suggestion to make, not an identity.
"""
rows = (await session.execute(
select(Source).where(Source.platform == membership.platform)
)).scalars().all()
for source in rows:
if match_kind(source, membership):
return source
return None