What you pay for, what Discord drops, and pixiv switched off #251
@@ -7,7 +7,9 @@ from ..extensions import get_session
|
||||
from ..models import DownloadEvent, MembershipSync, PlatformMembership, Source
|
||||
from ..services.artist_membership_service import ArtistMembershipService
|
||||
from ..services.artist_membership_service import rescan as membership_rescan
|
||||
from ..services.membership_roster import roster_is_fresh
|
||||
from ..services.artist_service import ArtistService
|
||||
from ..services.membership_reconcile import reconcile_all
|
||||
from ..services.membership_roster import roster_is_fresh, source_for_membership
|
||||
from ..services.scheduler_service import active_platform_cooldowns, scheduler_status
|
||||
from ..services.source_service import (
|
||||
KNOWN_PLATFORMS,
|
||||
@@ -386,3 +388,68 @@ async def rescan_membership_suggestions():
|
||||
result = await membership_rescan(session)
|
||||
await session.commit()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# --- #387 C4: reconciling the roster against what FC actually tracks -------
|
||||
#
|
||||
# Asymmetric on purpose. The "you subscribe but FC doesn't follow it" direction
|
||||
# carries a per-row action, because adding a source is the reversible half. The
|
||||
# "FC follows it but your roster doesn't show it" direction is REPORT ONLY by
|
||||
# the operator's decision (2026-09-11): it says what it sees and links to the
|
||||
# Subscriptions row, and offers no one-click disable.
|
||||
|
||||
|
||||
@sources_bp.route("/reconciliation", methods=["GET"])
|
||||
async def reconciliation():
|
||||
async with get_session() as session:
|
||||
return jsonify(await reconcile_all(session))
|
||||
|
||||
|
||||
@sources_bp.route("/reconciliation/adopt", methods=["POST"])
|
||||
async def adopt_membership():
|
||||
"""Start tracking a creator the roster says the account already pays for.
|
||||
|
||||
One row, one click, never a sweep side effect: adding a source commits disk,
|
||||
worker time and rate budget, and unwinding it means deleting files.
|
||||
"""
|
||||
body = await request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return _bad("invalid_body", status=400)
|
||||
membership_id = body.get("membership_id")
|
||||
if not isinstance(membership_id, int):
|
||||
return _bad("membership_id_required", status=400)
|
||||
|
||||
async with get_session() as session:
|
||||
membership = await session.get(PlatformMembership, membership_id)
|
||||
if membership is None:
|
||||
return _bad("membership_not_found", status=404)
|
||||
if not membership.url:
|
||||
return _bad("membership_has_no_url", status=400)
|
||||
|
||||
existing = await source_for_membership(session, membership)
|
||||
if existing is not None:
|
||||
# The operator got there by another route between the page load and
|
||||
# the click. That is them being ahead of us, not an error.
|
||||
return jsonify({"already_tracked": existing.id})
|
||||
|
||||
# The sweep already captured the creator's real display name, so the
|
||||
# artist gets its true name with NO lookup on the request path. Task
|
||||
# #1293 asked for `resolve_display_name` here; the roster satisfies that
|
||||
# concern earlier in the pipeline than #1293 expected, which also keeps
|
||||
# this route off the network entirely (rule 164). The vanity is the
|
||||
# fallback, never the preferred value.
|
||||
name = membership.display_name or membership.vanity_or_none()
|
||||
if not name:
|
||||
return _bad("membership_has_no_name", status=400)
|
||||
|
||||
artist, _created = await ArtistService(session).find_or_create(name)
|
||||
try:
|
||||
record = await SourceService(session).create(
|
||||
artist_id=artist.id,
|
||||
platform=membership.platform,
|
||||
url=membership.url,
|
||||
)
|
||||
except DuplicateSourceError as exc:
|
||||
return jsonify({"already_tracked": exc.existing_id})
|
||||
artist_id = artist.id
|
||||
return jsonify({"source_id": record.id, "artist_id": artist_id}), 201
|
||||
|
||||
@@ -75,6 +75,7 @@ from ..models import (
|
||||
)
|
||||
from ..utils.slug import slugify
|
||||
from ..utils.text import html_to_plain
|
||||
from .membership_roster import source_for_membership
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -204,6 +205,14 @@ class ArtistMembershipService:
|
||||
membership = await self.session.get(PlatformMembership, membership_id)
|
||||
if membership is None:
|
||||
return 0
|
||||
# The shared identity join (C4), used here as the NEGATIVE check. A
|
||||
# membership FC already has a source for is tracked — whoever it happens
|
||||
# to be filed under — and proposing it to some OTHER artist would be
|
||||
# exactly the wrong link this service exists to avoid making.
|
||||
# `_candidate_artists` only knows whether a GIVEN artist has a source on
|
||||
# the platform, which cannot see a source sitting under someone else.
|
||||
if await source_for_membership(self.session, membership) is not None:
|
||||
return 0
|
||||
already = await self._decided(membership_id)
|
||||
|
||||
made = 0
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Reconciling the learned roster against the sources FC actually tracks.
|
||||
|
||||
Milestone 387, step C4. The step the operator asked for; C0-C3 are what make it
|
||||
trustworthy enough to act on.
|
||||
|
||||
## The buckets
|
||||
|
||||
1. `subscribed_not_tracked` — you pay for this and FC does not follow it. The
|
||||
adoption win, and the only bucket carrying an action.
|
||||
2. `tracked_not_subscribed` — FC follows this and the roster does not show you
|
||||
paying for it. REPORT ONLY, by the operator's decision (2026-09-11): it says
|
||||
what it sees and links to the existing Subscriptions row, and offers no
|
||||
one-click disable.
|
||||
3. `matched` — the healthy set. Counted, not listed loudly.
|
||||
4. `unidentified` — sources this join cannot speak to at all. Reported as
|
||||
exactly that, because the alternative is filing them under a verdict.
|
||||
|
||||
## Why absence is the dangerous direction
|
||||
|
||||
Bucket 1 is safe to be wrong about: the cost of offering a source the operator
|
||||
does not want is one ignored row. Bucket 2 is not. It is computed from an
|
||||
ABSENCE — no membership matched — and three different things produce that
|
||||
absence: the subscription genuinely lapsed, the sweep failed, or the creator
|
||||
renamed and this source has never been walked so no exact id was ever cached.
|
||||
|
||||
Two guards follow from that, and they are the substance of this module:
|
||||
|
||||
* the whole bucket is gated on `roster_is_fresh`, so a failed or never-run sweep
|
||||
yields an empty list rather than a confident accusation (C3 built the state
|
||||
this reads);
|
||||
* every row carries the BASIS for its claim, so "your membership says former
|
||||
patron" and "we know this creator's id and it is not in your roster" and "we
|
||||
only have a URL handle to go on" are three different sentences rather than one
|
||||
overconfident one.
|
||||
|
||||
`has_paid_access` returning None is honoured throughout: unknown is never
|
||||
rendered as lapsed. That is the whole reason it returns a tri-state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import Artist, MembershipSync, PlatformMembership, Source
|
||||
from .membership_roster import (
|
||||
get_sync_state,
|
||||
has_paid_access,
|
||||
identity_keys_for_source,
|
||||
match_kind,
|
||||
roster_is_fresh,
|
||||
url_tail,
|
||||
)
|
||||
|
||||
# Why a source appears in `tracked_not_subscribed`. Ordered strongest first —
|
||||
# the UI renders a different sentence per basis, because collapsing them into
|
||||
# one would make the weakest claim sound like the strongest.
|
||||
BASIS_LAPSED = "lapsed" # a matched membership says access ended
|
||||
BASIS_ABSENT_EXACT = "absent_exact" # exact id known, not in a fresh roster
|
||||
BASIS_ABSENT_HANDLE = "absent_handle" # only a URL handle to go on
|
||||
|
||||
|
||||
def _membership_row(m: PlatformMembership) -> dict:
|
||||
return {
|
||||
"id": m.id,
|
||||
"platform": m.platform,
|
||||
"external_campaign_id": m.external_campaign_id,
|
||||
"display_name": m.display_name or m.vanity_or_none(),
|
||||
"url": m.url,
|
||||
"vanity": m.vanity_or_none(),
|
||||
"status": m.status,
|
||||
"tier_names": m.tier_names,
|
||||
"amount_cents": m.amount_cents,
|
||||
"currency": m.currency,
|
||||
"paid_access": has_paid_access(
|
||||
m.platform, m.status,
|
||||
is_free_member=bool((m.details or {}).get("is_free_member")),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _source_row(source: Source, artist: Artist) -> dict:
|
||||
return {
|
||||
"id": source.id,
|
||||
"platform": source.platform,
|
||||
"url": source.url,
|
||||
"enabled": source.enabled,
|
||||
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
|
||||
}
|
||||
|
||||
|
||||
async def reconcile(
|
||||
session: AsyncSession, *, platform: str, now: datetime | None = None,
|
||||
) -> dict:
|
||||
"""Sort one platform's memberships and sources into the four buckets.
|
||||
|
||||
Always returns the COMPLETE shape, including when the roster is not fresh —
|
||||
a caller reading `len(result["tracked_not_subscribed"])` must not have to
|
||||
check which keys exist first. `fresh` is what says whether the emptiness
|
||||
means anything.
|
||||
"""
|
||||
state = await get_sync_state(session, platform)
|
||||
fresh = roster_is_fresh(state, now=now)
|
||||
|
||||
memberships = (await session.execute(
|
||||
select(PlatformMembership).where(PlatformMembership.platform == platform)
|
||||
)).scalars().all()
|
||||
rows = (await session.execute(
|
||||
select(Source, Artist)
|
||||
.join(Artist, Artist.id == Source.artist_id)
|
||||
.where(Source.platform == platform)
|
||||
)).all()
|
||||
|
||||
# Nested loop rather than a SQL join: the match is a predicate over a JSON
|
||||
# blob and a derived URL handle, neither of which is indexable, and both
|
||||
# sides are tens of rows on any real library. Keeping it in Python means ONE
|
||||
# definition of identity (`match_kind`) instead of a second one in SQL that
|
||||
# could drift from it.
|
||||
pairs: dict[int, tuple[PlatformMembership, str]] = {}
|
||||
matched_membership_ids: set[int] = set()
|
||||
for source, _artist in rows:
|
||||
for m in memberships:
|
||||
kind = match_kind(source, m)
|
||||
if kind:
|
||||
pairs[source.id] = (m, kind)
|
||||
matched_membership_ids.add(m.id)
|
||||
break
|
||||
|
||||
subscribed_not_tracked = []
|
||||
for m in memberships:
|
||||
if m.id in matched_membership_ids:
|
||||
continue
|
||||
paid = has_paid_access(
|
||||
m.platform, m.status,
|
||||
is_free_member=bool((m.details or {}).get("is_free_member")),
|
||||
)
|
||||
# A membership FC knows has ENDED is not an adoption opportunity —
|
||||
# adding it would start a walk that can only fetch what is already
|
||||
# public. Unknown (None) is still offered: the operator can judge it,
|
||||
# and refusing to show it would hide a real subscription behind a word
|
||||
# this code has not been taught.
|
||||
if paid is False:
|
||||
continue
|
||||
subscribed_not_tracked.append(_membership_row(m))
|
||||
|
||||
tracked_not_subscribed = []
|
||||
matched = []
|
||||
unidentified = []
|
||||
for source, artist in rows:
|
||||
pair = pairs.get(source.id)
|
||||
if pair is not None:
|
||||
m, kind = pair
|
||||
paid = has_paid_access(
|
||||
m.platform, m.status,
|
||||
is_free_member=bool((m.details or {}).get("is_free_member")),
|
||||
)
|
||||
if paid is False:
|
||||
if not source.enabled:
|
||||
# Already off. Reporting a source the operator has already
|
||||
# stopped following is noise, not a finding.
|
||||
continue
|
||||
row = _source_row(source, artist)
|
||||
row["basis"] = BASIS_LAPSED
|
||||
row["matched_by"] = kind
|
||||
row["membership"] = _membership_row(m)
|
||||
tracked_not_subscribed.append(row)
|
||||
else:
|
||||
row = _source_row(source, artist)
|
||||
row["matched_by"] = kind
|
||||
row["membership"] = _membership_row(m)
|
||||
matched.append(row)
|
||||
continue
|
||||
|
||||
# No membership matched. Whether that MEANS anything depends entirely on
|
||||
# how well this source can be identified at all.
|
||||
has_exact = bool(identity_keys_for_source(source))
|
||||
if not has_exact and url_tail(source.url) is None:
|
||||
# Nothing to match on — a sidecar anchor or a URL with no handle.
|
||||
# Reported as unidentified rather than silently dropped, so the
|
||||
# counts add up to the source list the operator can see.
|
||||
unidentified.append(_source_row(source, artist))
|
||||
continue
|
||||
if not source.enabled:
|
||||
# Already off. Telling the operator to stop following something they
|
||||
# have stopped following is noise, not a finding.
|
||||
continue
|
||||
row = _source_row(source, artist)
|
||||
row["basis"] = BASIS_ABSENT_EXACT if has_exact else BASIS_ABSENT_HANDLE
|
||||
row["matched_by"] = None
|
||||
row["membership"] = None
|
||||
tracked_not_subscribed.append(row)
|
||||
|
||||
# THE GATE. Everything above computed the bucket; this decides whether it may
|
||||
# be shown. A stale or never-run roster makes every absence meaningless, and
|
||||
# an absence rendered as a verdict is how this feature would tell the
|
||||
# operator to cancel something they are still paying for.
|
||||
if not fresh:
|
||||
tracked_not_subscribed = []
|
||||
|
||||
return {
|
||||
"platform": platform,
|
||||
"fresh": fresh,
|
||||
# How many sources exist on this platform at all. The UI needs it to
|
||||
# decide whether an untrustworthy roster is worth mentioning: with no
|
||||
# sources here there is nothing to reconcile, and a stale-roster warning
|
||||
# would be noise on an install that simply has not started yet (that
|
||||
# empty-install case is C6's, not this card's).
|
||||
"tracked_total": len(rows),
|
||||
"last_success_at": (
|
||||
state.last_success_at.isoformat()
|
||||
if state is not None and state.last_success_at else None
|
||||
),
|
||||
"subscribed_not_tracked": subscribed_not_tracked,
|
||||
"tracked_not_subscribed": tracked_not_subscribed,
|
||||
"matched": matched,
|
||||
"unidentified": unidentified,
|
||||
}
|
||||
|
||||
|
||||
async def reconcile_all(session: AsyncSession, now: datetime | None = None) -> dict:
|
||||
"""Every platform the roster knows about, in one payload for the UI.
|
||||
|
||||
The platform list is the UNION of platforms with memberships and platforms
|
||||
with sync state, not just the former. A sweep that has never succeeded has
|
||||
recorded zero memberships, and deriving the list from memberships alone
|
||||
would drop exactly that platform from the payload — making a broken
|
||||
credential indistinguishable from a platform FC was never asked about. That
|
||||
distinction is the whole reason C3 records sync state.
|
||||
"""
|
||||
with_memberships = (await session.execute(
|
||||
select(PlatformMembership.platform).distinct()
|
||||
)).scalars().all()
|
||||
with_state = (await session.execute(
|
||||
select(MembershipSync.platform)
|
||||
)).scalars().all()
|
||||
platforms = set(with_memberships) | set(with_state)
|
||||
return {
|
||||
"platforms": [
|
||||
await reconcile(session, platform=p, now=now) for p in sorted(platforms)
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<!-- Renders nothing when there is nothing to say — same posture as
|
||||
NeedsAttentionCard. A reconciliation card that always shows would train
|
||||
the operator to scroll past it. -->
|
||||
<v-card v-if="anythingToSay" variant="tonal" class="mb-4 fc-recon">
|
||||
<v-card-text>
|
||||
<div v-for="p in interesting" :key="p.platform" class="fc-recon__platform">
|
||||
<!-- Bucket 1: the adoption win. The only direction with an action,
|
||||
because adding a source is the reversible half. -->
|
||||
<template v-if="p.subscribed_not_tracked.length">
|
||||
<div class="fc-recon__head">
|
||||
<v-icon icon="mdi-account-plus-outline" size="small" class="me-2" />
|
||||
<strong>
|
||||
{{ p.subscribed_not_tracked.length }}
|
||||
{{ p.platform }}
|
||||
{{ p.subscribed_not_tracked.length === 1 ? 'creator' : 'creators' }}
|
||||
you subscribe to but don't follow here
|
||||
</strong>
|
||||
</div>
|
||||
<div v-for="m in p.subscribed_not_tracked" :key="m.id" class="fc-recon__row">
|
||||
<div class="fc-recon__body">
|
||||
<strong>{{ m.display_name }}</strong>
|
||||
<span v-if="m.tier_names?.length" class="fc-recon__dim">
|
||||
· {{ m.tier_names.join(', ') }}
|
||||
</span>
|
||||
<!-- A membership whose status this build has not been taught is
|
||||
still offered, and says so rather than being presented as a
|
||||
confirmed active subscription. -->
|
||||
<span v-if="m.paid_access === null" class="fc-recon__dim">
|
||||
· status not recognised ({{ m.status }})
|
||||
</span>
|
||||
</div>
|
||||
<v-btn size="small" variant="tonal" @click="store.adopt(m.id)">
|
||||
Add subscription
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Bucket 2: REPORT ONLY. No disable control here by design — the
|
||||
source list on this same page is where that decision belongs. -->
|
||||
<template v-if="p.tracked_not_subscribed.length">
|
||||
<div class="fc-recon__head">
|
||||
<v-icon icon="mdi-help-circle-outline" size="small" class="me-2" />
|
||||
<strong>
|
||||
{{ p.tracked_not_subscribed.length }}
|
||||
{{ p.platform }}
|
||||
{{ p.tracked_not_subscribed.length === 1 ? 'source' : 'sources' }}
|
||||
your roster doesn't account for
|
||||
</strong>
|
||||
</div>
|
||||
<div v-for="s in p.tracked_not_subscribed" :key="s.id" class="fc-recon__row">
|
||||
<div class="fc-recon__body">
|
||||
<strong>{{ s.artist.name }}</strong>
|
||||
<div class="fc-recon__dim">{{ reasonFor(s) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fc-recon__dim fc-recon__note">
|
||||
Nothing has been changed. If you want one of these to stop checking,
|
||||
disable it in the list below.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- The roster could not be trusted, so bucket 2 was not computed. Said
|
||||
in words: an empty list here must never read as "all clear". -->
|
||||
<div v-if="!p.fresh" class="fc-recon__dim fc-recon__note">
|
||||
<v-icon icon="mdi-clock-alert-outline" size="small" class="me-1" />
|
||||
<template v-if="!p.last_success_at">
|
||||
Your {{ p.platform }} roster has never synced, so FC can't tell which
|
||||
sources you still subscribe to.
|
||||
</template>
|
||||
<template v-else>
|
||||
Your {{ p.platform }} roster last synced
|
||||
{{ formatRelative(p.last_success_at) }}, which is too old to draw
|
||||
conclusions from.
|
||||
</template>
|
||||
Sync it from Settings → Subscription roster.
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { formatRelative } from '../../utils/date.js'
|
||||
import { useMembershipReconcileStore } from '../../stores/membershipReconcile.js'
|
||||
|
||||
// #387 C4. Two directions, deliberately unequal: subscriptions FC doesn't
|
||||
// follow get a one-click add, while sources the roster doesn't account for are
|
||||
// REPORTED ONLY (operator decision, 2026-09-11). The asymmetry is the point —
|
||||
// adding a source is trivially undone, and "you no longer subscribe to this" is
|
||||
// computed from an absence that has three possible causes.
|
||||
const store = useMembershipReconcileStore()
|
||||
|
||||
// An untrustworthy roster is only worth mentioning when there is something it
|
||||
// would have reconciled — otherwise a failed sweep on a platform with no
|
||||
// sources yet would put a warning on a page with nothing to warn about.
|
||||
const interesting = computed(() => store.platforms.filter(
|
||||
p => p.subscribed_not_tracked.length
|
||||
|| p.tracked_not_subscribed.length
|
||||
|| (!p.fresh && p.tracked_total)
|
||||
))
|
||||
const anythingToSay = computed(() => interesting.value.length > 0)
|
||||
|
||||
function reasonFor (s) {
|
||||
if (s.basis === 'lapsed') {
|
||||
return `Your membership reads "${s.membership?.status}" — you're not a paying supporter of this creator right now.`
|
||||
}
|
||||
if (s.basis === 'absent_exact') {
|
||||
return "FC knows this creator's id on the platform, and it isn't in your roster."
|
||||
}
|
||||
// absent_handle — the weakest claim, and it says so. A renamed creator looks
|
||||
// exactly like this, which is why it is not phrased as a conclusion.
|
||||
return "No membership matched this source's address. It may simply have been renamed."
|
||||
}
|
||||
|
||||
onMounted(() => { store.load() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-recon__platform + .fc-recon__platform {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.fc-recon__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
.fc-recon__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
.fc-recon__body { flex: 1; min-width: 0; }
|
||||
/* Rule 96: dim text is the theme token, never bare opacity. */
|
||||
.fc-recon__dim {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.fc-recon__note { margin-top: 8px; }
|
||||
</style>
|
||||
@@ -7,6 +7,9 @@
|
||||
nothing to say. -->
|
||||
<NeedsAttentionCard />
|
||||
<RecentArrivalsCard />
|
||||
<!-- #387 C4: what you pay for vs. what FC follows. Renders nothing when
|
||||
the two agree. -->
|
||||
<MembershipReconcileCard />
|
||||
|
||||
<div class="fc-subs__bar">
|
||||
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
||||
@@ -327,6 +330,7 @@ import { usePlatformsStore } from '../../stores/platforms.js'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
import NeedsAttentionCard from './NeedsAttentionCard.vue'
|
||||
import RecentArrivalsCard from './RecentArrivalsCard.vue'
|
||||
import MembershipReconcileCard from './MembershipReconcileCard.vue'
|
||||
import SourceRow from './SourceRow.vue'
|
||||
import SourceCard from './SourceCard.vue'
|
||||
import SourceHealthDot from './SourceHealthDot.vue'
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
import { toast } from '../utils/toast.js'
|
||||
|
||||
// Backs the reconciliation card (#387 C4). Two directions with deliberately
|
||||
// different weights: adopting a creator you already pay for is a per-row
|
||||
// action, while "you follow this and your roster doesn't show it" only ever
|
||||
// REPORTS — the operator disables from the Subscriptions row itself.
|
||||
//
|
||||
// `fresh` is the field that matters most here. A stale or never-run roster
|
||||
// returns an empty tracked_not_subscribed list, and the card must say WHY it is
|
||||
// empty rather than letting it read as "everything is fine".
|
||||
export const useMembershipReconcileStore = defineStore('membershipReconcile', () => {
|
||||
const api = useApi()
|
||||
const platforms = ref([])
|
||||
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
||||
|
||||
async function load () {
|
||||
await run(async () => {
|
||||
const body = await api.get('/api/sources/reconciliation')
|
||||
platforms.value = body.platforms || []
|
||||
})
|
||||
}
|
||||
|
||||
async function adopt (membershipId) {
|
||||
try {
|
||||
const res = await api.post('/api/sources/reconciliation/adopt', {
|
||||
membership_id: membershipId
|
||||
})
|
||||
toast({
|
||||
text: res.already_tracked
|
||||
? 'Already tracked — nothing to add'
|
||||
: 'Now tracking this creator',
|
||||
type: 'success'
|
||||
})
|
||||
// Reload rather than splice: adopting moves the row from one bucket to
|
||||
// another, and the counts beside them have to move with it.
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast({ text: `Could not add: ${e?.body?.detail || e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return { platforms, loading, error, load, adopt }
|
||||
})
|
||||
@@ -166,6 +166,30 @@ async def test_an_artist_already_on_that_platform_is_never_proposed(db):
|
||||
assert await ArtistMembershipService(db).match_membership(m.id) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_membership_already_tracked_elsewhere_is_never_proposed(db):
|
||||
"""C4's shared identity join, used here as the NEGATIVE check.
|
||||
|
||||
A membership FC already has a source for is tracked — even when that source
|
||||
sits under a DIFFERENT artist — and proposing it to this one would be the
|
||||
wrong link this service exists to avoid. `_candidate_artists` cannot see
|
||||
this on its own: it only knows whether the artist in front of it has a
|
||||
source on the platform, not whether someone else already does.
|
||||
"""
|
||||
await _artist_with_discord(db, "Maewix", "maewix")
|
||||
other = Artist(name="Someone Else", slug="someoneelse")
|
||||
db.add(other)
|
||||
await db.flush()
|
||||
db.add(Source(
|
||||
artist_id=other.id, platform="patreon",
|
||||
url="https://www.patreon.com/maewix", enabled=True,
|
||||
))
|
||||
m = await _membership(db, display_name="Maewix")
|
||||
await db.commit()
|
||||
|
||||
assert await ArtistMembershipService(db).match_membership(m.id) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_artist_with_no_sources_at_all_is_never_proposed(db):
|
||||
"""Not a creator FC follows through another channel, which is the whole
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Milestone 387 C4: what you pay for, against what FC actually follows.
|
||||
|
||||
The buckets are easy; the honesty is the work. `tracked_not_subscribed` is
|
||||
computed from an ABSENCE — no membership matched this source — and three
|
||||
different things produce that absence: the subscription really lapsed, the
|
||||
sweep failed, or the creator renamed and this source has never been walked so
|
||||
no exact id was ever cached. Only the first means what the bucket's name says.
|
||||
|
||||
So most of what follows pins refusals: the gate that empties the bucket when the
|
||||
roster cannot be trusted, the tri-state that keeps "unknown" out of "lapsed",
|
||||
and the per-row basis that stops the weakest claim sounding like the strongest.
|
||||
"""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.models import Artist, MembershipSync, PlatformMembership, Source
|
||||
from backend.app.services.membership_reconcile import (
|
||||
BASIS_ABSENT_EXACT,
|
||||
BASIS_ABSENT_HANDLE,
|
||||
BASIS_LAPSED,
|
||||
reconcile,
|
||||
reconcile_all,
|
||||
)
|
||||
from backend.app.services.membership_roster import ROSTER_STALE_AFTER
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def _artist(db, name="Maewix"):
|
||||
a = Artist(name=name, slug=name.lower().replace(" ", ""))
|
||||
db.add(a)
|
||||
await db.flush()
|
||||
return a
|
||||
|
||||
|
||||
async def _source(db, artist, *, url, platform="patreon", enabled=True, overrides=None):
|
||||
s = Source(
|
||||
artist_id=artist.id, platform=platform, url=url, enabled=enabled,
|
||||
config_overrides=overrides,
|
||||
)
|
||||
db.add(s)
|
||||
await db.flush()
|
||||
return s
|
||||
|
||||
|
||||
async def _membership(
|
||||
db, *, campaign="c1", platform="patreon", status="active_patron",
|
||||
display_name="Maewix", url="https://www.patreon.com/maewix", details=None,
|
||||
):
|
||||
m = PlatformMembership(
|
||||
platform=platform, external_campaign_id=campaign, status=status,
|
||||
display_name=display_name, url=url,
|
||||
details={"campaign": {"vanity": "maewix"}} if details is None else details,
|
||||
)
|
||||
db.add(m)
|
||||
await db.flush()
|
||||
return m
|
||||
|
||||
|
||||
async def _synced(db, *, platform="patreon", ago=timedelta(hours=1)):
|
||||
db.add(MembershipSync(platform=platform, last_success_at=datetime.now(UTC) - ago))
|
||||
await db.flush()
|
||||
|
||||
|
||||
# --- the join --------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_cached_campaign_id_matches_regardless_of_the_url(db):
|
||||
"""The exact key doing the work. The URL deliberately does NOT agree, so a
|
||||
match here can only have come from the cached id."""
|
||||
a = await _artist(db)
|
||||
await _source(
|
||||
db, a, url="https://www.patreon.com/an-old-handle",
|
||||
overrides={"patreon_campaign_id": "c1"},
|
||||
)
|
||||
await _membership(db, campaign="c1")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
out = await reconcile(db, platform="patreon")
|
||||
assert len(out["matched"]) == 1
|
||||
assert out["matched"][0]["matched_by"] == "campaign"
|
||||
assert out["subscribed_not_tracked"] == []
|
||||
assert out["tracked_not_subscribed"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_handle_matches_when_nothing_has_been_cached_yet(db):
|
||||
"""The fallback that exists because the id is only written AFTER a source
|
||||
has been walked once — a freshly added source has no id at all."""
|
||||
a = await _artist(db)
|
||||
await _source(db, a, url="https://www.patreon.com/maewix")
|
||||
await _membership(db, campaign="c1")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
out = await reconcile(db, platform="patreon")
|
||||
assert len(out["matched"]) == 1
|
||||
assert out["matched"][0]["matched_by"] == "vanity"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_c_and_cw_url_forms_both_reduce_to_the_vanity(db):
|
||||
"""Patreon serves /c/<vanity> and /cw/<vanity> (#3886). A handle derivation
|
||||
that missed these is the exact class of bug in #1485."""
|
||||
a = await _artist(db)
|
||||
await _source(db, a, url="https://www.patreon.com/c/maewix")
|
||||
await _membership(db, campaign="c1")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
assert len((await reconcile(db, platform="patreon"))["matched"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_same_id_on_another_platform_is_not_a_match(db):
|
||||
"""Nothing stops two platforms minting the same opaque id."""
|
||||
a = await _artist(db)
|
||||
await _source(
|
||||
db, a, url="https://subscribestar.adult/maewix", platform="subscribestar",
|
||||
overrides={"subscribestar_campaign_id": "c1"},
|
||||
)
|
||||
await _membership(db, campaign="c1", platform="patreon")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
out = await reconcile(db, platform="patreon")
|
||||
assert out["matched"] == []
|
||||
assert len(out["subscribed_not_tracked"]) == 1
|
||||
|
||||
|
||||
# --- the gate: an untrustworthy roster accuses nobody -----------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_stale_roster_produces_no_unaccounted_sources(db):
|
||||
"""THE property. A sweep that stopped working a week ago must not turn every
|
||||
source into "you no longer subscribe to this"."""
|
||||
a = await _artist(db)
|
||||
await _source(
|
||||
db, a, url="https://www.patreon.com/tracked",
|
||||
overrides={"patreon_campaign_id": "not-in-the-roster"},
|
||||
)
|
||||
await _synced(db, ago=ROSTER_STALE_AFTER + timedelta(days=1))
|
||||
await db.commit()
|
||||
|
||||
out = await reconcile(db, platform="patreon")
|
||||
assert out["fresh"] is False
|
||||
assert out["tracked_not_subscribed"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_never_synced_roster_produces_no_unaccounted_sources(db):
|
||||
"""Never-synced is not zero. With no MembershipSync row at all there is no
|
||||
evidence of anything, and the bucket must stay empty."""
|
||||
a = await _artist(db)
|
||||
await _source(
|
||||
db, a, url="https://www.patreon.com/tracked",
|
||||
overrides={"patreon_campaign_id": "whatever"},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
out = await reconcile(db, platform="patreon")
|
||||
assert out["fresh"] is False
|
||||
assert out["last_success_at"] is None
|
||||
assert out["tracked_not_subscribed"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_shape_is_complete_even_when_the_roster_is_stale(db):
|
||||
"""A caller must be able to read any bucket unconditionally — the E2 lesson
|
||||
where a shorter disabled payload broke an exact-shape assertion."""
|
||||
await _synced(db, ago=ROSTER_STALE_AFTER + timedelta(days=1))
|
||||
await db.commit()
|
||||
|
||||
out = await reconcile(db, platform="patreon")
|
||||
assert set(out) == {
|
||||
"platform", "fresh", "tracked_total", "last_success_at",
|
||||
"subscribed_not_tracked", "tracked_not_subscribed", "matched",
|
||||
"unidentified",
|
||||
}
|
||||
|
||||
|
||||
# --- what lands in the report-only bucket, and why -------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_lapsed_membership_reports_its_source_as_lapsed(db):
|
||||
a = await _artist(db)
|
||||
await _source(db, a, url="https://www.patreon.com/maewix")
|
||||
await _membership(db, campaign="c1", status="former_patron")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
out = await reconcile(db, platform="patreon")
|
||||
assert len(out["tracked_not_subscribed"]) == 1
|
||||
assert out["tracked_not_subscribed"][0]["basis"] == BASIS_LAPSED
|
||||
assert out["matched"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_known_id_missing_from_a_fresh_roster_is_the_strong_claim(db):
|
||||
a = await _artist(db)
|
||||
await _source(
|
||||
db, a, url="https://www.patreon.com/tracked",
|
||||
overrides={"patreon_campaign_id": "not-in-the-roster"},
|
||||
)
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
out = await reconcile(db, platform="patreon")
|
||||
assert out["tracked_not_subscribed"][0]["basis"] == BASIS_ABSENT_EXACT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_handle_only_source_makes_the_weaker_claim(db):
|
||||
"""A renamed creator looks exactly like this, so it must not be phrased the
|
||||
same way as the case where the id is known."""
|
||||
a = await _artist(db)
|
||||
await _source(db, a, url="https://www.patreon.com/some-old-name")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
out = await reconcile(db, platform="patreon")
|
||||
assert out["tracked_not_subscribed"][0]["basis"] == BASIS_ABSENT_HANDLE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_already_disabled_source_is_not_reported(db):
|
||||
"""Telling the operator to stop following something they have already
|
||||
stopped following is noise, not a finding."""
|
||||
a = await _artist(db)
|
||||
await _source(db, a, url="https://www.patreon.com/gone", enabled=False)
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
assert (await reconcile(db, platform="patreon"))["tracked_not_subscribed"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_sidecar_anchor_is_unidentified_not_unsubscribed(db):
|
||||
"""Pre-0030 synthetic anchors are not feeds. They cannot be matched, so they
|
||||
are reported as unmatchable rather than filed under a verdict."""
|
||||
a = await _artist(db)
|
||||
await _source(db, a, url="sidecar:patreon:maewix", enabled=False)
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
out = await reconcile(db, platform="patreon")
|
||||
assert len(out["unidentified"]) == 1
|
||||
assert out["tracked_not_subscribed"] == []
|
||||
|
||||
|
||||
# --- what is offered for adoption, and what is withheld --------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_membership_with_no_source_is_offered(db):
|
||||
await _membership(db, campaign="c1")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
out = await reconcile(db, platform="patreon")
|
||||
assert len(out["subscribed_not_tracked"]) == 1
|
||||
assert out["subscribed_not_tracked"][0]["paid_access"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_lapsed_membership_is_not_offered_for_adoption(db):
|
||||
"""Adding it would start a walk that can only fetch what is already public."""
|
||||
await _membership(db, campaign="c1", status="former_patron")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
assert (await reconcile(db, platform="patreon"))["subscribed_not_tracked"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unrecognised_status_is_still_offered(db):
|
||||
"""None is not False. Withholding a membership because this build has not
|
||||
been taught its status word would hide a real subscription."""
|
||||
await _membership(db, campaign="c1", status="a_word_nobody_characterised")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
out = await reconcile(db, platform="patreon")
|
||||
assert len(out["subscribed_not_tracked"]) == 1
|
||||
assert out["subscribed_not_tracked"][0]["paid_access"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_free_follow_is_not_offered_as_a_subscription(db):
|
||||
"""`is_free_member` is a second axis: a CURRENT membership nobody pays for
|
||||
is not a subscription to adopt."""
|
||||
await _membership(
|
||||
db, campaign="c1", status="active_patron",
|
||||
details={"campaign": {"vanity": "maewix"}, "is_free_member": True},
|
||||
)
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
assert (await reconcile(db, platform="patreon"))["subscribed_not_tracked"] == []
|
||||
|
||||
|
||||
# --- the all-platforms payload ---------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_platform_whose_sweep_never_succeeded_still_appears(db):
|
||||
"""Deriving the platform list from memberships alone would drop exactly the
|
||||
platform whose credential is broken — making it indistinguishable from one
|
||||
FC was never asked about."""
|
||||
db.add(MembershipSync(platform="patreon", last_error_type="PatreonAuthError"))
|
||||
await db.commit()
|
||||
|
||||
out = await reconcile_all(db)
|
||||
assert [p["platform"] for p in out["platforms"]] == ["patreon"]
|
||||
assert out["platforms"][0]["fresh"] is False
|
||||
Reference in New Issue
Block a user