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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user