Lapsed subscriptions stop pulling, a quieter Subscriptions card, and working feed filters (#254)
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 6s
Build images / build-ml (push) Successful in 12s
Build images / build-web (push) Successful in 12s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 2m30s

This commit was merged in pull request #254.
This commit is contained in:
2026-09-13 22:45:03 -04:00
13 changed files with 603 additions and 158 deletions
+10
View File
@@ -65,6 +65,16 @@ async def autocomplete():
]) ])
@artists_bp.route("/names", methods=["GET"])
async def names():
"""Every artist, id + name + slug, alphabetical. For filter pickers that
list artists before anything is typed; `autocomplete` deliberately returns
nothing for an empty query."""
async with get_session() as session:
rows = await ArtistService(session).all_names()
return jsonify([{"id": i, "name": n, "slug": s} for i, n, s in rows])
@artists_bp.route("/directory", methods=["GET"]) @artists_bp.route("/directory", methods=["GET"])
async def directory(): async def directory():
"""FC-3f: cursor-paginated artists directory. """FC-3f: cursor-paginated artists directory.
+12
View File
@@ -300,6 +300,18 @@ class ArtistService:
await self.session.commit() await self.session.commit()
return artist return artist
async def all_names(self) -> list[tuple[int, str, str]]:
"""Every artist as (id, name, slug), alphabetical.
For pickers that should show a full list before anything is typed (the
Latest feed's artist filter). Three columns and no joins, so it stays
cheap on a library of thousands of artists.
"""
rows = (await self.session.execute(
select(Artist.id, Artist.name, Artist.slug).order_by(func.lower(Artist.name))
)).all()
return [(r.id, r.name, r.slug) for r in rows]
async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]: async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]:
cleaned = (prefix or "").strip() cleaned = (prefix or "").strip()
if not cleaned: if not cleaned:
+128 -4
View File
@@ -8,9 +8,10 @@ trustworthy enough to act on.
1. `subscribed_not_tracked` — you pay for this and FC does not follow it. The 1. `subscribed_not_tracked` — you pay for this and FC does not follow it. The
adoption win, and the only bucket carrying an action. adoption win, and the only bucket carrying an action.
2. `tracked_not_subscribed` — FC follows this and the roster does not show you 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 paying for it. No longer shown on the card: the operator reversed the
what it sees and links to the existing Subscriptions row, and offers no 2026-09-11 "report only" call on 2026-09-13. The lapsed half of it now ACTS,
one-click disable. in `apply_membership_lapses` below (#3995). The absent half still only
reports, because absence proves nothing.
3. `matched` — the healthy set. Counted, not listed loudly. 3. `matched` — the healthy set. Counted, not listed loudly.
4. `unidentified` — sources this join cannot speak to at all. Reported as 4. `unidentified` — sources this join cannot speak to at all. Reported as
exactly that, because the alternative is filing them under a verdict. exactly that, because the alternative is filing them under a verdict.
@@ -39,7 +40,7 @@ rendered as lapsed. That is the whole reason it returns a tri-state.
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -233,3 +234,126 @@ async def reconcile_all(session: AsyncSession, now: datetime | None = None) -> d
await reconcile(session, platform=p, now=now) for p in sorted(platforms) await reconcile(session, platform=p, now=now) for p in sorted(platforms)
] ]
} }
# ---------------------------------------------------------------------------
# Stop pulling what the account no longer pays for (#3995)
# ---------------------------------------------------------------------------
#
# Operator decision, 2026-09-13, reversing the 2026-09-11 "report only" call
# for this direction: "if I kill a subscription on patreon I would like the
# pulling to stop on curator as well", with automatic resume on resubscribing.
#
# This is a SOURCE-level action taken by the daily sweep, visible on the source
# row and reversible there. It is not a fetch-path decision. The line C5 draws,
# that the roster never decides a POST is inaccessible, still holds: nothing
# here reads per-post access, and no download path reads the roster
# (`test_no_fetch_path_can_read_the_roster`). The scheduler keeps selecting on
# `enabled` alone.
#
# Acts ONLY on positive evidence. A source whose matched membership says access
# has ended is stopped. A source with NO matched membership is left alone,
# because absence has innocent causes: a creator rename, a source never walked
# so no id is cached, a membership the platform stopped listing. Stopping on
# absence would switch off things the operator still pays for.
#
# Two app-managed config_overrides keys carry the state. The `_` prefix is
# already the "FC writes this, an operator edit preserves it" family.
# _membership_stopped set when the sweep stops a source; the sweep resumes
# ONLY sources carrying it, so a source the operator
# switched off by hand is never switched back on
# _membership_kept set by SourceService.update when the operator turns a
# stopped source back ON: a deliberate choice to keep
# pulling a lapsed creator, which the next sweep must
# not undo. Cleared when the membership is paid again.
STOPPED_KEY = "_membership_stopped"
KEPT_KEY = "_membership_kept"
def _access_expires_at(m: PlatformMembership) -> datetime | None:
"""When paid access actually ends, if the platform says.
Patreon keeps a cancelled membership's access until the end of the billing
period and reports that date (`member.access_expires_at`, note #3992).
SubscribeStar's page gives no such date, so a cancelled SubscribeStar
membership stops at once. Returns None when there is no usable date.
"""
details = m.details or {}
raw = details.get("access_expires_at") or (details.get("member") or {}).get("access_expires_at")
if not isinstance(raw, str) or not raw:
return None
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
async def apply_membership_lapses(
session: AsyncSession, *, platform: str, now: datetime | None = None,
) -> dict:
"""Stop sources whose paid access has ended; resume the ones this stopped.
Refuses to act on a roster that isn't fresh, for the same reason C4 refuses
to draw conclusions from one.
"""
now = now or datetime.now(UTC)
state = await get_sync_state(session, platform)
if not roster_is_fresh(state, now=now):
return {"platform": platform, "skipped": "roster not fresh", "stopped": 0, "resumed": 0}
memberships = (await session.execute(
select(PlatformMembership).where(PlatformMembership.platform == platform)
)).scalars().all()
sources = (await session.execute(
select(Source).where(Source.platform == platform)
)).scalars().all()
pairs = pair_sources_with_memberships(list(sources), list(memberships))
stopped: list[int] = []
resumed: list[int] = []
for source in sources:
pair = pairs.get(source.id)
if pair is None:
continue # absence is never acted on, see above
m, _kind = pair
paid = has_paid_access(
m.platform, m.status,
is_free_member=bool((m.details or {}).get("is_free_member")),
)
co = dict(source.config_overrides or {})
if paid is True:
changed = co.pop(KEPT_KEY, None) is not None
if STOPPED_KEY in co:
co.pop(STOPPED_KEY)
source.enabled = True
resumed.append(source.id)
changed = True
if changed:
source.config_overrides = co
continue
# Unknown status: never a reason to stop something (has_paid_access's
# tri-state exists for exactly this).
if paid is None:
continue
if not source.enabled or co.get(KEPT_KEY):
continue
expires = _access_expires_at(m)
if expires is not None and expires > now:
continue # still inside the paid-through period
co[STOPPED_KEY] = {"at": now.isoformat(), "status": m.status}
source.config_overrides = co
source.enabled = False
# The same clean slate a manual disable gives (SourceService.update,
# #1285), so a stopped source doesn't linger as failing or gated.
source.last_error = None
source.error_type = None
source.consecutive_failures = 0
stopped.append(source.id)
await session.commit()
return {"platform": platform, "stopped": len(stopped), "resumed": len(resumed)}
+23
View File
@@ -19,6 +19,7 @@ from ..models import (
) )
from .db_helpers import failing_sources_clause from .db_helpers import failing_sources_clause
from .gallery_dl import ErrorType from .gallery_dl import ErrorType
from .membership_reconcile import KEPT_KEY, STOPPED_KEY
from .membership_roster import gated_reasons_for_sources from .membership_roster import gated_reasons_for_sources
from .platforms import known_platform_keys from .platforms import known_platform_keys
from .scheduler_service import compute_next_check_at from .scheduler_service import compute_next_check_at
@@ -171,6 +172,25 @@ def arm_backfill(source: Source) -> None:
source.backfill_runs_remaining = BACKFILL_MAX_CHUNKS source.backfill_runs_remaining = BACKFILL_MAX_CHUNKS
def _record_manual_enable_choice(source: Source, *, enabled: bool) -> None:
"""Keep the membership sweep (#3995) from overriding the operator.
Turning a source the sweep STOPPED back on is a deliberate choice to keep
pulling a lapsed creator, so it is marked kept and the next sweep leaves it
alone. Turning a source off by hand drops any sweep marker, so the sweep
never switches back on something the operator switched off themselves.
"""
co = dict(source.config_overrides or {})
if enabled and STOPPED_KEY in co:
co.pop(STOPPED_KEY)
co[KEPT_KEY] = True
elif not enabled:
co.pop(STOPPED_KEY, None)
else:
return
source.config_overrides = co
class SourceService: class SourceService:
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
@@ -428,6 +448,9 @@ class SourceService:
for key, value in fields.items(): for key, value in fields.items():
setattr(source, key, value) setattr(source, key, value)
if "enabled" in fields:
_record_manual_enable_choice(source, enabled=bool(fields["enabled"]))
if url_changed: if url_changed:
# Repointing a source at a different creator makes a cached campaign # Repointing a source at a different creator makes a cached campaign
# id WRONG, not merely stale, and `patreon_resolver` consults that # id WRONG, not merely stale, and `patreon_resolver` consults that
+19 -4
View File
@@ -1247,6 +1247,7 @@ def sync_memberships() -> str:
from ..services.artist_membership_service import rescan as membership_rescan from ..services.artist_membership_service import rescan as membership_rescan
from ..services.credential_crypto import CredentialCrypto from ..services.credential_crypto import CredentialCrypto
from ..services.credential_service import CredentialService from ..services.credential_service import CredentialService
from ..services.membership_reconcile import apply_membership_lapses
from ..services.membership_roster import roster_user_id, sync_platform from ..services.membership_roster import roster_user_id, sync_platform
from ..services.patreon_client import PatreonClient from ..services.patreon_client import PatreonClient
from ..services.subscribestar_client import SubscribeStarClient from ..services.subscribestar_client import SubscribeStarClient
@@ -1298,9 +1299,18 @@ def sync_memberships() -> str:
) )
async with async_factory() as session: async with async_factory() as session:
results.append( result = await sync_platform(session, platform=platform, fetch=fetch)
await sync_platform(session, platform=platform, fetch=fetch) results.append(result)
)
# #3995: stop pulling sources whose paid access has ended, and
# resume the ones this stopped once they are paid again. Only
# right after a successful sync, so it always acts on the roster
# just written, never on a stale one.
if result.get("ok"):
async with async_factory() as session:
result["lapses"] = await apply_membership_lapses(
session, platform=platform,
)
# #388 E4: offer the freshly-synced roster to the artists FC already # #388 E4: offer the freshly-synced roster to the artists FC already
# tracks. Chained here rather than given its own beat entry because # tracks. Chained here rather than given its own beat entry because
@@ -1321,7 +1331,12 @@ def sync_memberships() -> str:
if "skipped" in r: if "skipped" in r:
parts.append(f"{r['platform']}=skipped({r['skipped']})") parts.append(f"{r['platform']}=skipped({r['skipped']})")
elif r.get("ok"): elif r.get("ok"):
parts.append(f"{r['platform']}={r['count']}") lapses = r.get("lapses") or {}
detail = (
f"(stopped={lapses['stopped']},resumed={lapses['resumed']})"
if lapses.get("stopped") or lapses.get("resumed") else ""
)
parts.append(f"{r['platform']}={r['count']}{detail}")
else: else:
parts.append(f"{r['platform']}=FAILED({r['error']})") parts.append(f"{r['platform']}=FAILED({r['error']})")
if res.get("suggested") is not None: if res.get("suggested") is not None:
@@ -1,21 +1,22 @@
<template> <template>
<div class="fc-posts-filters"> <div class="fc-posts-filters">
<!-- The full artist list, filtered client-side as you type. This used to
search the server only once something was typed, so opening the
dropdown showed an empty menu. -->
<v-autocomplete <v-autocomplete
v-model="artistModel" v-model="artistModel"
:items="artistOptions" :items="artistOptions"
:loading="artistLoading" :loading="artistLoading"
:search="artistQuery"
item-title="name" item-title="name"
item-value="id" item-value="id"
label="Artist" label="Artist"
density="compact" density="compact"
hide-details hide-details
clearable clearable
no-filter
return-object return-object
no-data-text="No matching artists"
class="fc-posts-filters__artist" class="fc-posts-filters__artist"
@update:search="onArtistSearch" @update:model-value="emitFilters"
@update:model-value="onArtistPicked"
/> />
<v-select <v-select
@@ -39,7 +40,8 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useSourcesStore } from '../../stores/sources.js'
import { useApi } from '../../composables/useApi.js'
import { usePlatformsStore } from '../../stores/platforms.js' import { usePlatformsStore } from '../../stores/platforms.js'
const props = defineProps({ const props = defineProps({
@@ -48,18 +50,19 @@ const props = defineProps({
}) })
const emit = defineEmits(['update:filters']) const emit = defineEmits(['update:filters'])
const sourcesStore = useSourcesStore() const api = useApi()
const platformsStore = usePlatformsStore() const platformsStore = usePlatformsStore()
// Artist autocomplete state
const artistQuery = ref('')
const artistOptions = ref([]) const artistOptions = ref([])
const artistLoading = ref(false) const artistLoading = ref(false)
const artistModel = ref(null) const artistModel = ref(null)
// Platform v-select state // `list`, not `platforms`: the store has never had a `platforms` property, so
// this read undefined and the dropdown opened empty.
// test/storeUsage.spec.js now fails on any read of a property the store does
// not define.
const platformItems = computed(() => const platformItems = computed(() =>
(platformsStore.platforms || []).map(p => ({ title: p.key, value: p.key })) platformsStore.list.map(p => ({ title: p.name || p.key, value: p.key }))
) )
const platformModel = ref(props.platform) const platformModel = ref(props.platform)
@@ -67,29 +70,6 @@ const hasFilters = computed(
() => artistModel.value != null || platformModel.value != null () => artistModel.value != null || platformModel.value != null
) )
let _searchTimer = null
function onArtistSearch(q) {
artistQuery.value = q
clearTimeout(_searchTimer)
if (!q || !q.trim()) {
artistOptions.value = artistModel.value ? [artistModel.value] : []
return
}
_searchTimer = setTimeout(async () => {
artistLoading.value = true
try {
const results = await sourcesStore.autocompleteArtist(q, 20)
artistOptions.value = results
} finally {
artistLoading.value = false
}
}, 200)
}
function onArtistPicked() {
emitFilters()
}
function emitFilters() { function emitFilters() {
emit('update:filters', { emit('update:filters', {
artist_id: artistModel.value?.id ?? null, artist_id: artistModel.value?.id ?? null,
@@ -100,30 +80,38 @@ function emitFilters() {
function clearAll() { function clearAll() {
artistModel.value = null artistModel.value = null
platformModel.value = null platformModel.value = null
artistOptions.value = []
artistQuery.value = ''
emitFilters() emitFilters()
} }
onMounted(async () => { // A deep link carries only the id. Once the list is loaded, show the real name
await platformsStore.loadAll() // rather than a placeholder.
// If a deep-link arrives with an artist_id, hydrate the selected display. function selectArtistById(id) {
if (props.artistId != null) { if (id == null) {
const seed = { id: props.artistId, name: `Artist #${props.artistId}` } artistModel.value = null
artistModel.value = seed return
artistOptions.value = [seed]
} }
if (artistModel.value?.id === id) return
artistModel.value = artistOptions.value.find(a => a.id === id)
|| { id, name: `Artist #${id}` }
}
async function loadArtists() {
artistLoading.value = true
try {
artistOptions.value = await api.get('/api/artists/names')
} catch {
artistOptions.value = []
} finally {
artistLoading.value = false
}
}
onMounted(async () => {
await Promise.all([platformsStore.loadAll(), loadArtists()])
selectArtistById(props.artistId)
}) })
watch(() => props.artistId, (val) => { watch(() => props.artistId, (val) => selectArtistById(val))
if (val == null) {
artistModel.value = null
} else if (artistModel.value?.id !== val) {
const seed = { id: val, name: `Artist #${val}` }
artistModel.value = seed
artistOptions.value = [seed]
}
})
watch(() => props.platform, (val) => { watch(() => props.platform, (val) => {
if (platformModel.value !== val) platformModel.value = val || null if (platformModel.value !== val) platformModel.value = val || null
@@ -1,12 +1,20 @@
<template> <template>
<!-- Renders nothing when there is nothing to say same posture as <!-- Renders nothing when there is nothing to say same posture as
NeedsAttentionCard. A reconciliation card that always shows would train NeedsAttentionCard. A reconciliation card that always shows would train
the operator to scroll past it. --> the operator to scroll past it. It can also be DISMISSED, and stays
<v-card v-if="anythingToSay" variant="tonal" class="mb-4 fc-recon"> dismissed until what it would say changes. -->
<v-card v-if="visible" variant="tonal" class="mb-4 fc-recon">
<v-card-text> <v-card-text>
<v-btn
icon="mdi-close" size="small" variant="text" class="fc-recon__dismiss"
title="Hide until something changes" aria-label="Hide until something changes"
@click="dismiss"
/>
<div v-for="p in interesting" :key="p.platform" class="fc-recon__platform"> <div v-for="p in interesting" :key="p.platform" class="fc-recon__platform">
<!-- Bucket 1: the adoption win. The only direction with an action, <!-- The adoption win: subscriptions FC doesn't follow yet. The only
because adding a source is the reversible half. --> bucket shown. Sources you follow but no longer pay for are not
listed here (operator, 2026-09-13) the membership sweep stops
pulling those instead (#3995). -->
<template v-if="p.subscribed_not_tracked.length"> <template v-if="p.subscribed_not_tracked.length">
<div class="fc-recon__head"> <div class="fc-recon__head">
<v-icon icon="mdi-account-plus-outline" size="small" class="me-2" /> <v-icon icon="mdi-account-plus-outline" size="small" class="me-2" />
@@ -36,37 +44,13 @@
</div> </div>
</template> </template>
<!-- Bucket 2: REPORT ONLY. No disable control here by design — the <!-- The roster could not be trusted. Said in words: an empty list here
source list on this same page is where that decision belongs. --> must never read as "all clear". -->
<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"> <div v-if="!p.fresh" class="fc-recon__dim fc-recon__note">
<v-icon icon="mdi-clock-alert-outline" size="small" class="me-1" /> <v-icon icon="mdi-clock-alert-outline" size="small" class="me-1" />
<template v-if="!p.last_success_at"> <template v-if="!p.last_success_at">
Your {{ p.platform }} roster has never synced, so FC can't tell which Your {{ p.platform }} roster has never synced, so FC can't tell which
sources you still subscribe to. creators you subscribe to.
</template> </template>
<template v-else> <template v-else>
Your {{ p.platform }} roster last synced Your {{ p.platform }} roster last synced
@@ -81,44 +65,62 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted } from 'vue' import { computed, onMounted, ref } from 'vue'
import { formatRelative } from '../../utils/date.js' import { formatRelative } from '../../utils/date.js'
import { useMembershipReconcileStore } from '../../stores/membershipReconcile.js' import { useMembershipReconcileStore } from '../../stores/membershipReconcile.js'
// #387 C4. Two directions, deliberately unequal: subscriptions FC doesn't // #387 C4. Offers the subscriptions FC doesn't follow yet. The other direction
// follow get a one-click add, while sources the roster doesn't account for are // (sources FC follows that the roster says you no longer pay for) used to be
// REPORTED ONLY (operator decision, 2026-09-11). The asymmetry is the point — // listed here as a report. The operator didn't want it listed (2026-09-13); the
// adding a source is trivially undone, and "you no longer subscribe to this" is // membership sweep stops pulling those sources instead (#3995).
// computed from an absence that has three possible causes.
const store = useMembershipReconcileStore() const store = useMembershipReconcileStore()
// An untrustworthy roster is only worth mentioning when there is something it // An untrustworthy roster is only worth mentioning when there is something it
// would have reconciled — otherwise a failed sweep on a platform with no // 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. // sources yet would put a warning on a page with nothing to warn about.
const interesting = computed(() => store.platforms.filter( const interesting = computed(() => store.platforms.filter(
p => p.subscribed_not_tracked.length p => p.subscribed_not_tracked.length || (!p.fresh && p.tracked_total)
|| p.tracked_not_subscribed.length
|| (!p.fresh && p.tracked_total)
)) ))
const anythingToSay = computed(() => interesting.value.length > 0)
function reasonFor (s) { // Dismissal is keyed to WHAT the card says, not to the card: a fingerprint of
if (s.basis === 'lapsed') { // the offered memberships and any stale-roster warnings. Dismissing hides this
return `Your membership reads "${s.membership?.status}" — you're not a paying supporter of this creator right now.` // exact set; a new subscription (or a roster going stale) changes the
} // fingerprint and brings the card back. Per-browser, which is enough for a
if (s.basis === 'absent_exact') { // single-operator instance.
return "FC knows this creator's id on the platform, and it isn't in your roster." const DISMISS_KEY = 'fc.recon.dismissed'
} const fingerprint = computed(() => interesting.value
// absent_handle — the weakest claim, and it says so. A renamed creator looks .flatMap(p => [
// exactly like this, which is why it is not phrased as a conclusion. ...p.subscribed_not_tracked.map(m => `${p.platform}:${m.id}`),
return "No membership matched this source's address. It may simply have been renamed." ...(p.fresh ? [] : [`${p.platform}:stale`]),
])
.sort()
.join('|'))
function readDismissed () {
try { return localStorage.getItem(DISMISS_KEY) } catch { return null }
} }
const dismissed = ref(readDismissed())
function dismiss () {
dismissed.value = fingerprint.value
try { localStorage.setItem(DISMISS_KEY, fingerprint.value) } catch { /* private mode */ }
}
const visible = computed(
() => interesting.value.length > 0 && fingerprint.value !== dismissed.value,
)
onMounted(() => { store.load() }) onMounted(() => { store.load() })
</script> </script>
<style scoped> <style scoped>
.fc-recon { position: relative; }
.fc-recon__dismiss {
position: absolute;
top: 6px;
right: 6px;
}
.fc-recon__platform + .fc-recon__platform { .fc-recon__platform + .fc-recon__platform {
margin-top: 16px; margin-top: 16px;
} }
@@ -39,8 +39,25 @@
{{ formatRelative(source.next_check_at, { future: true }) }} {{ formatRelative(source.next_check_at, { future: true }) }}
</td> </td>
<td> <td>
<!-- #3995: the membership sweep stopped this source because paid access
ended. First in the chain it explains why an otherwise healthy
source is off. Neutral, never error: nothing is broken. -->
<v-chip <v-chip
v-if="(source.consecutive_failures || 0) > 0" v-if="membershipStopped"
size="x-small" variant="tonal" label
prepend-icon="mdi-account-cancel-outline"
>Membership ended
<v-tooltip activator="parent" location="top" max-width="420">
<span>
Curator stopped checking this source because your membership
{{ membershipStopped.status ? `reads "${membershipStopped.status}"` : 'ended' }}.
It starts again on its own if you resubscribe. Turn it back on to
keep checking anyway.
</span>
</v-tooltip>
</v-chip>
<v-chip
v-else-if="(source.consecutive_failures || 0) > 0"
size="x-small" color="error" variant="tonal" label size="x-small" color="error" variant="tonal" label
>{{ source.consecutive_failures }} >{{ source.consecutive_failures }}
<!-- #1: show the actual failure reason on hover instead of a bare count. --> <!-- #1: show the actual failure reason on hover instead of a bare count. -->
@@ -109,6 +126,12 @@ const props = defineProps({
// operator's subscription, not anything FC can retry. The count is only joined // operator's subscription, not anything FC can retry. The count is only joined
// in by the list endpoint, so phrase it without one when it's absent rather // in by the list endpoint, so phrase it without one when it's absent rather
// than rendering a fabricated zero. // than rendering a fabricated zero.
// #3995: set by the membership sweep when it stops a source whose paid access
// ended; cleared when it resumes, or when the operator toggles the source.
const membershipStopped = computed(
() => props.source.config_overrides?._membership_stopped || null,
)
const noAccessTip = computed(() => { const noAccessTip = computed(() => {
const n = props.source.tier_gated_count const n = props.source.tier_gated_count
const what = n const what = n
+3 -2
View File
@@ -58,8 +58,9 @@ useInfiniteScroll(sentinelEl, () => {
onMounted(async () => { onMounted(async () => {
await platformsStore.loadAll() await platformsStore.loadAll()
platformItems.value = (platformsStore.platforms || []).map(p => ({ // `list` — the store has no `platforms` property (test/storeUsage.spec.js).
title: p.key, value: p.key, platformItems.value = platformsStore.list.map(p => ({
title: p.name || p.key, value: p.key,
})) }))
// Apply any deep-linked q (and load). setQuery resets + fetches. // Apply any deep-linked q (and load). setQuery resets + fetches.
store.setQuery(searchTerm.value || '') store.setQuery(searchTerm.value || '')
+26 -45
View File
@@ -1,7 +1,7 @@
<template> <template>
<!-- Width is set in CSS, not with `max-width` here: below 1600px it is <!-- Width is set in CSS, not with `max-width` here: below 1600px it is
today's 900px column, and above it the feed widens and gains the rail today's 900px column, and above it the feed widens and gains the day
and day gutter (milestone #407). --> gutter (milestone #407). -->
<v-container fluid class="pt-2 pb-6 fc-posts"> <v-container fluid class="pt-2 pb-6 fc-posts">
<!-- In-context view: deep-linked to one post, with bidirectional infinite <!-- In-context view: deep-linked to one post, with bidirectional infinite
scroll — newer posts load above, older posts below. --> scroll — newer posts load above, older posts below. -->
@@ -51,19 +51,20 @@
</template> </template>
<!-- Normal feed --> <!-- Normal feed -->
<div v-else class="fc-posts__layout"> <div v-else class="fc-posts__main">
<!-- On a wide window this is a sticky left rail (#407 E); below the <!-- Filters and status sit in one row above the feed. #407 option E put
breakpoint it lays out exactly as the old inline header did. --> them in a sticky left rail; the operator found it wasted space
<aside class="fc-posts__rail"> (2026-09-13), so on a wide window this row lines up with the feed
column instead, clear of the day gutter. -->
<div class="fc-posts__toolbar">
<FeedStatusRibbon v-if="statusRibbon" class="fc-posts__status" />
<PostsFilterBar <PostsFilterBar
:artist-id="artistFilter" :artist-id="artistFilter"
:platform="platformFilter" :platform="platformFilter"
@update:filters="onFilters" @update:filters="onFilters"
/> />
<FeedStatusRibbon v-if="statusRibbon" /> </div>
</aside>
<div class="fc-posts__main">
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3"> <v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
{{ String(store.error) }} {{ String(store.error) }}
</v-alert> </v-alert>
@@ -101,7 +102,6 @@
<span v-else-if="store.done" class="fc-posts__end">End of stream</span> <span v-else-if="store.done" class="fc-posts__end">End of stream</span>
</div> </div>
</div> </div>
</div>
</div> </div>
</v-container> </v-container>
</template> </template>
@@ -313,44 +313,25 @@ onUnmounted(() => { teardownFeed(); teardownAround() })
} }
.fc-posts__day-count { font-size: 0.78rem; } .fc-posts__day-count { font-size: 0.78rem; }
/* Wide window (#407 D + E). The rail holds filters and status; each day's /* Wide window (#407 A + D). Each day's heading moves into a sticky gutter
heading moves into a sticky gutter beside its posts; the column widens and beside its posts; the column widens and the cards switch to their filmstrip
the cards switch to their filmstrip layout on their own (PostCard measures layout on their own (PostCard measures itself). The toolbar lines up with the
itself). 1600px is where a 280px rail and a 150px gutter still leave a card feed column, clear of the gutter. */
wide enough to be worth the change. */
@media (min-width: 1600px) { @media (min-width: 1600px) {
.fc-posts { max-width: 2360px; } .fc-posts { max-width: 2080px; }
.fc-posts__layout { .fc-posts__main { max-width: 2080px; margin: 0 auto; }
display: grid; .fc-posts__toolbar {
grid-template-columns: 280px minmax(0, 1fr);
gap: 40px;
align-items: start;
}
.fc-posts__rail {
position: sticky;
top: calc(var(--fc-nav-h, 64px) + 16px);
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 16px; flex-wrap: wrap;
gap: 8px 24px;
margin-left: calc(150px + 24px);
margin-bottom: 8px;
} }
.fc-posts__rail :deep(.fc-posts-filters) { /* Status reads after the filters on a wide row, but stays first when the
flex-direction: column; narrow layout stacks them (it is the front door's headline). */
align-items: stretch; .fc-posts__status { order: 2; padding: 0; }
padding-bottom: 0; .fc-posts__toolbar :deep(.fc-posts-filters) { padding-bottom: 0; }
}
.fc-posts__rail :deep(.fc-posts-filters__artist),
.fc-posts__rail :deep(.fc-posts-filters__platform) {
flex: none;
width: 100%;
min-width: 0;
max-width: none;
}
.fc-posts__rail :deep(.fc-ribbon) {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.fc-posts__main { max-width: 1900px; }
.fc-posts__day { .fc-posts__day {
display: grid; display: grid;
grid-template-columns: 150px minmax(0, 1fr); grid-template-columns: 150px minmax(0, 1fr);
+63
View File
@@ -0,0 +1,63 @@
// Reads of a store property that does not exist.
//
// JavaScript returns `undefined` for a missing property instead of failing, and
// the frontend CI has no type-checker to catch it. `platformsStore.platforms`
// was read for months by the Latest feed's filter bar and the Artists view. The
// store exposes `list` and `byKey`, so both platform dropdowns opened empty and
// only someone clicking them noticed.
//
// This scans the source for `platformsStore.<name>` and checks each name
// against what the store really defines.
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createPinia, setActivePinia } from 'pinia'
import { describe, expect, it } from 'vitest'
import { usePlatformsStore } from '../src/stores/platforms.js'
const SRC = fileURLToPath(new URL('../src', import.meta.url))
const READ = /\bplatformsStore\.([A-Za-z_$][\w$]*)/g
function sourceFiles (dir) {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name)
if (statSync(path).isDirectory()) return sourceFiles(path)
return /\.(vue|js)$/.test(name) ? [path] : []
})
}
function unknownReads (text, known) {
return [...text.matchAll(READ)].map(m => m[1]).filter(name => !known.has(name))
}
function storeKeys () {
setActivePinia(createPinia())
return new Set(Object.keys(usePlatformsStore()))
}
describe('platforms store usage', () => {
it('every platformsStore.<name> read in src names something the store defines', () => {
const known = storeKeys()
const offenders = sourceFiles(SRC).flatMap((file) =>
unknownReads(readFileSync(file, 'utf8'), known)
.map(name => `${file.slice(SRC.length + 1)}: platformsStore.${name}`),
)
expect(offenders).toEqual([])
})
it('the scan finds the read that shipped broken (positive control)', () => {
const known = storeKeys()
expect(known.has('list')).toBe(true)
expect(unknownReads('(platformsStore.platforms || []).map(p => p)', known))
.toEqual(['platforms'])
})
it('the scan actually walks the source tree', () => {
const hasRead = /\bplatformsStore\./
const withReads = sourceFiles(SRC).filter(f => hasRead.test(readFileSync(f, 'utf8')))
expect(withReads.length).toBeGreaterThan(2)
})
})
+13
View File
@@ -72,3 +72,16 @@ async def test_patch_rename_validation(client):
f"/api/artists/{created['id']}", json={})).status_code == 400 f"/api/artists/{created['id']}", json={})).status_code == 400
assert (await client.patch( assert (await client.patch(
"/api/artists/999999", json={"name": "Ghost"})).status_code == 404 "/api/artists/999999", json={"name": "Ghost"})).status_code == 404
@pytest.mark.asyncio
async def test_names_lists_every_artist_alphabetically(client):
"""The Latest feed's artist filter shows this list before anything is typed.
`autocomplete` stays empty for an empty query; this is the full list."""
for name in ("zed", "Alice", "bob"):
await client.post("/api/artists", json={"name": name})
resp = await client.get("/api/artists/names")
assert resp.status_code == 200
body = await resp.get_json()
assert [a["name"] for a in body] == ["Alice", "bob", "zed"]
assert set(body[0]) == {"id", "name", "slug"}
+190
View File
@@ -0,0 +1,190 @@
"""#3995: stop pulling a source once the account stops paying, resume on resubscribe.
Operator, 2026-09-13: "if I kill a subscription on patreon I would like the
pulling to stop on curator as well", with automatic resume when they resubscribe.
Stopping a source is easy. Not stopping the wrong one is the work, so most of
these pin refusals: an absent membership is never a lapse, an unknown status is
never a lapse, a stale roster decides nothing, a paid-through period is honoured,
and the operator's own on/off choice always outranks the sweep in both directions.
"""
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import select
from backend.app.models import Artist, Source
from backend.app.services.membership_reconcile import (
KEPT_KEY,
STOPPED_KEY,
apply_membership_lapses,
)
from backend.app.services.membership_roster import ROSTER_STALE_AFTER
from backend.app.services.source_service import SourceService
from tests.roster_builders import membership as _membership
from tests.roster_builders import synced as _synced
pytestmark = pytest.mark.integration
async def _source(
db, *, overrides=None, enabled=True, url="https://www.patreon.com/an-old-handle",
):
artist = Artist(name="Maewix", slug="maewix")
db.add(artist)
await db.flush()
s = Source(
artist_id=artist.id, platform="patreon", url=url, enabled=enabled,
config_overrides={"patreon_campaign_id": "c1", **(overrides or {})},
last_error="boom", error_type="tier_limited", consecutive_failures=3,
)
db.add(s)
await db.flush()
return s.id
async def _state(db, source_id):
row = (await db.execute(
select(Source.enabled, Source.config_overrides, Source.error_type,
Source.consecutive_failures).where(Source.id == source_id)
)).one()
return row
@pytest.mark.asyncio
async def test_a_lapsed_membership_stops_its_source_with_a_clean_slate(db):
sid = await _source(db)
await _membership(db, campaign="c1", status="former_patron")
await _synced(db)
await db.commit()
out = await apply_membership_lapses(db, platform="patreon")
assert out["stopped"] == 1
enabled, co, error_type, failures = await _state(db, sid)
assert enabled is False
assert co[STOPPED_KEY]["status"] == "former_patron"
assert co["patreon_campaign_id"] == "c1" # the identity cache survives
assert (error_type, failures) == (None, 0)
@pytest.mark.asyncio
async def test_the_paid_through_period_is_honoured(db):
"""Patreon keeps access until the billing period ends, and says when."""
sid = await _source(db)
later = (datetime.now(UTC) + timedelta(days=10)).isoformat()
await _membership(
db, campaign="c1", status="former_patron",
details={"member": {"access_expires_at": later}},
)
await _synced(db)
await db.commit()
assert (await apply_membership_lapses(db, platform="patreon"))["stopped"] == 0
assert (await _state(db, sid)).enabled is True
@pytest.mark.asyncio
async def test_an_absent_membership_is_never_a_lapse(db):
"""No match has innocent causes (a rename, a never-walked source), so
absence alone must never switch a source off."""
sid = await _source(db, overrides={"patreon_campaign_id": "nobody-has-this"})
await _membership(db, campaign="c1", status="former_patron", details={})
await _synced(db)
await db.commit()
assert (await apply_membership_lapses(db, platform="patreon"))["stopped"] == 0
assert (await _state(db, sid)).enabled is True
@pytest.mark.asyncio
async def test_an_unrecognised_status_is_never_a_lapse(db):
sid = await _source(db)
await _membership(db, campaign="c1", status="some_word_nobody_characterised")
await _synced(db)
await db.commit()
assert (await apply_membership_lapses(db, platform="patreon"))["stopped"] == 0
assert (await _state(db, sid)).enabled is True
@pytest.mark.asyncio
async def test_a_stale_roster_decides_nothing(db):
sid = await _source(db)
await _membership(db, campaign="c1", status="former_patron")
await _synced(db, ago=ROSTER_STALE_AFTER + timedelta(hours=1))
await db.commit()
out = await apply_membership_lapses(db, platform="patreon")
assert out["skipped"] == "roster not fresh"
assert (await _state(db, sid)).enabled is True
@pytest.mark.asyncio
async def test_resubscribing_resumes_only_what_the_sweep_stopped(db):
stopped = await _source(
db, enabled=False, overrides={STOPPED_KEY: {"status": "former_patron"}},
)
await _membership(db, campaign="c1", status="active_patron")
await _synced(db)
await db.commit()
assert (await apply_membership_lapses(db, platform="patreon"))["resumed"] == 1
enabled, co, _e, _f = await _state(db, stopped)
assert enabled is True
assert STOPPED_KEY not in co
@pytest.mark.asyncio
async def test_a_source_the_operator_switched_off_is_never_switched_back_on(db):
sid = await _source(db, enabled=False)
await _membership(db, campaign="c1", status="active_patron")
await _synced(db)
await db.commit()
assert (await apply_membership_lapses(db, platform="patreon"))["resumed"] == 0
assert (await _state(db, sid)).enabled is False
@pytest.mark.asyncio
async def test_turning_a_stopped_source_back_on_keeps_it_on(db):
"""Re-enabling a stopped source by hand is a choice to keep pulling a lapsed
creator. The next sweep must not undo it."""
sid = await _source(
db, enabled=False, overrides={STOPPED_KEY: {"status": "former_patron"}},
)
await _membership(db, campaign="c1", status="former_patron")
await _synced(db)
await db.commit()
await SourceService(db).update(sid, enabled=True)
enabled, co, _e, _f = await _state(db, sid)
assert enabled is True
assert co.get(KEPT_KEY) is True and STOPPED_KEY not in co
assert (await apply_membership_lapses(db, platform="patreon"))["stopped"] == 0
assert (await _state(db, sid)).enabled is True
@pytest.mark.asyncio
async def test_switching_a_stopped_source_off_by_hand_drops_the_resume_marker(db):
sid = await _source(
db, enabled=False, overrides={STOPPED_KEY: {"status": "former_patron"}},
)
await db.commit()
await SourceService(db).update(sid, enabled=False)
_enabled, co, _e, _f = await _state(db, sid)
assert STOPPED_KEY not in co
@pytest.mark.asyncio
async def test_a_kept_source_is_released_once_paid_again(db):
sid = await _source(db, overrides={KEPT_KEY: True})
await _membership(db, campaign="c1", status="active_patron")
await _synced(db)
await db.commit()
await apply_membership_lapses(db, platform="patreon")
_enabled, co, _e, _f = await _state(db, sid)
assert KEPT_KEY not in co