Lapsed subscriptions stop pulling, a quieter Subscriptions card, and working feed filters #254
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 || '')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user