Compare commits

...
6 Commits
Author SHA1 Message Date
bvandeusen 667b05f14e Merge pull request 'Extension probe-and-add (v1.0.6) + per-commit image tags' (#40) from dev into main
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 22s
extension / lint (push) Successful in 8s
Build images / sign-extension (push) Successful in 1m51s
Build images / build-web (push) Failing after 4s
CI / intimp (push) Successful in 3m38s
CI / intapi (push) Successful in 7m20s
CI / intcore (push) Successful in 7m45s
2026-06-01 01:44:03 -04:00
bvandeusen 8de7ccd07d build(ci): per-commit :c-<short_sha> tag on main-push per family rule #46
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m40s
CI / intcore (push) Successful in 8m15s
extension / lint (pull_request) Successful in 16s
CI / lint (push) Successful in 2s
Adds an immutable per-commit docker tag to every main-push build:
`git.fabledsword.com/bvandeusen/fabledcurator{,-ml}:c-<short_sha>`,
alongside the existing floating `:main` + `:latest`. Implements the
new family release-posture rule "Tags are milestones, not gates —
commit-SHA images are the rollback unit" so rollback to any commit
on main is `docker pull …:c-<sha>` with no release ceremony required.

Behavior change summary:
- main-push: was {:main, :latest} → now {:main, :latest, :c-<short_sha>}
- tag-push (opt-in vYY.MM.DD only, no .N): unchanged
- safety-net dev: unchanged

No code changes; the rule is about how the tag list is constructed.
Tag-push workflows stay as-is — vYY.MM.DD milestone cuts can still
fire them when the operator wants a labeled checkpoint.
2026-06-01 01:28:55 -04:00
bvandeusen d65f0b2091 feat(extension): probe shows current state before click; v1.0.6
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 20s
extension / lint (push) Successful in 13s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m11s
CI / intcore (push) Successful in 7m41s
Operator-asked 2026-05-31 (during sidecar synthetic anchor cleanup):
"the add source/subscription button idea to the firefox extension so
it can tell me if a source/artist is added or not and offer an option
to add it if it isn't." Plan tracked in Scribe task #507.

## Backend

- `ExtensionService.probe(url)` — read-only resolution. Reuses
  `_derive` for platform+slug, then 2 SELECTs. Returns one of:
  - `source_match` (exact (artist, platform, url) Source exists)
  - `artist_match` (artist exists, this URL isn't a Source yet;
     collapses the sidecar-synthetic-only case from v26.06.01.0)
  - `new` (neither exists)
  - `unknown_platform` (URL didn't match any artist-page regex)
- `GET /api/extension/probe?url=...` route with `X-Extension-Key`
  auth posture matching `/quick-add-source`. Read-only, side-effect
  free.
- 6 backend tests in tests/test_api_extension.py covering each state
  + auth + invalid URL.

## Extension

- `api.js`: `probeSource(url)` mirroring `quickAddSource` shape.
- `background.js`: `PROBE_SOURCE` + `OPEN_ARTIST_PAGE` handlers. The
  latter strips the `/api` suffix from configured `apiUrl` (placeholder
  format per options.html) and opens `${base}/artist/{slug}` in a new
  tab via `browser.tabs.create`.
- `content-script.js`: probe-first render — on page-load and SPA
  navigation, asks the backend for the URL's state and renders the
  chip in the matching color/copy on FIRST paint instead of flashing
  generic "Add" and updating after. Click handler branches:
  `source_match` → OPEN_ARTIST_PAGE; `artist_match`/`new` → existing
  ADD_AS_SOURCE flow (then re-probes so the chip flips green
  immediately, no wait for next nav).
- `content-script.css`: three state-color modifiers
  (--new, --artist-match, --source-match) on the FC parchment-on-slate
  palette. Sage for already-added, amber for artist-exists, accent
  orange for new.

## Versioning

- `extension/manifest.json` + `extension/package.json` → 1.0.6.
  build.yml's sign-extension job will fire on push to main since no
  `ext-1.0.6` Forgejo/Gitea release exists yet — exercises the
  regenerated AMO keys end-to-end.

## Behavior on the sidecar-synthetic case

Filesystem-imported "Dymkens"-style artist with only a sidecar
synthetic Source: probe returns `artist_match` (not `new`), so the
chip reads "+ Add Patreon source to Dymkens" rather than offering to
recreate the artist. Clicking adds the real Source; existing
`_source_for_sidecar` preference logic (v26.06.01.0) routes future
gallery-dl Posts to the real one.
2026-06-01 00:41:17 -04:00
bvandeusen 856e9104b4 Merge pull request 'Sidecar synthetic anchor cleanup + tier-gated classifier fix' (#39) from dev into main
CI / lint (push) Successful in 5s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
CI / intimp (push) Successful in 3m54s
Build images / sign-extension (push) Has been skipped
Build images / build-web (push) Successful in 1m1s
Build images / build-ml (push) Successful in 1m21s
CI / intapi (push) Successful in 7m31s
CI / intcore (push) Successful in 8m0s
2026-06-01 00:16:58 -04:00
bvandeusen 66f19d67f5 fix(download): tier-gated = warning, race subprocess timeout, install yt-dlp
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 24s
CI / intimp (push) Successful in 3m44s
CI / intapi (push) Successful in 6m56s
CI / intcore (push) Successful in 7m35s
Three coupled operator-reported pains from the 2026-05-31 download
event audit:

1. `[patreon][warning] Not allowed to view post N` was bubbling up as
   an error event, bumping consecutive_failures and parking the source
   in "needs attention." The classifier's tier-gated branch was gated
   on `return_code in (1, 4)`. Gallery-dl returns a different exit
   code for mixed-failure runs (e.g. paywall warnings + a missing
   yt-dlp dep flipping the exit bits), so the branch never fired and
   the path fell through to UNKNOWN_ERROR. Widen the gate: when no
   source-level error fired AND tier-gated warnings are present,
   classify as TIER_LIMITED regardless of return code.

2. Knuxy event #38275 (2026-05-31) ran 30 min and finalized with
   "stranded by recovery sweep (no terminal status after time_limit)"
   + empty stdout/stderr. Root cause: subprocess.run timeout (900s)
   and Celery soft_time_limit (900s) raced; when Celery won, SIGKILL
   wiped the in-memory captured output and the DownloadEvent ended up
   empty-logged 18 minutes later when the sweep finalized it. Drop
   gallery-dl's default subprocess timeout to 870s — a 30s margin
   shy of Celery's soft limit — so subprocess.TimeoutExpired always
   wins the race and captures the partial stdout/stderr via the
   existing handler.

3. `[downloader.ytdl][error] Cannot import yt-dlp or youtube-dl` was
   firing on every video attachment, causing per-item download
   failures that masked legitimate tier-gated classification.
   Add yt-dlp>=2025.1 to requirements.txt. Once it's in the image,
   video posts download normally and the per-item failure noise
   disappears.

Tests added:
- pure tier-gated stderr with exit code 128 → TIER_LIMITED + success
- mixed tier-gated + yt-dlp + per-item failures → still TIER_LIMITED
2026-05-31 23:30:39 -04:00
bvandeusen 6fc8ae3106 fix(subscriptions): hide sidecar synthetic Sources + prefer real on lookup
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 20s
CI / frontend-build (push) Successful in 19s
CI / intimp (push) Successful in 3m42s
CI / intapi (push) Successful in 7m35s
CI / intcore (push) Successful in 8m20s
Two coupled bugs surfaced 2026-05-31 by the Subscriptions UI showing
"phantom" subscriptions like `sidecar:patreon:dpmaker`:

1. `SourceService.list()` returned every Source, no filter on URL.
   alembic 0022 (2026-05-26) consolidated old per-post-URL Sources into
   one canonical row per (artist, platform); when no real campaign URL
   was salvageable it rewrote the canonical to `sidecar:<plat>:<slug>`
   enabled=false as a disabled anchor. The UI then listed those
   anchors as if they were polls — disabled, but visible. Fix: `list()`
   excludes `url LIKE 'sidecar:%'` by default; `include_synthetic=True`
   opts back in for admin tooling.

2. `importer._source_for_sidecar` picked the lowest-id Source for
   (artist, platform). When alembic 0022 had rewritten a per-post row
   into a synthetic anchor (lower id) AND the operator later added the
   real subscription (higher id), every gallery-dl download silently
   attached its Post to the SYNTHETIC instead of the real Source. Fix:
   prefer a non-`sidecar:%` URL when one exists; fall back to the
   synthetic; only create a new synthetic when nothing exists for
   (artist, platform).

alembic 0028 is the data half: for every (artist, platform) with both
a synthetic AND a real Source, pre-merge Post+ImageProvenance
collisions on the canonical, bulk-repoint Posts/ImageProvenance/
DownloadEvent.source_id onto the real Source, and delete the
synthetic. Lone synthetics (no real twin) are left intact — they
anchor real imported content the operator may still want; the
list-filter hides them so they no longer surface as phantoms.
2026-05-31 23:08:38 -04:00
18 changed files with 731 additions and 51 deletions
+22 -12
View File
@@ -242,20 +242,28 @@ jobs:
id: tag id: tag
run: | run: |
# Three trigger shapes: # Three trigger shapes:
# refs/tags/v… → tag-push: publish ONLY the immutable version # refs/tags/v… → tag-push: opt-in milestone label (vYY.MM.DD,
# tag (e.g. :v26.05.26.5). Don't touch :latest; # no `.N` per family release-posture rule).
# that already got published by the main-push # Publish ONLY the immutable version tag;
# build for the merge commit. # don't touch :latest (the main-push build
# refs/heads/main → push to main (incl. PR merge commits): # for the merge commit already did that).
# publish :main + :latest (floating). # refs/heads/main → push to main: publish :main + :latest
# (floating) AND :c-<short_sha> (immutable
# per-commit rollback substrate, per family
# release-posture rule "Tags are milestones,
# not gates — commit-SHA images are the
# rollback unit"). Rollback to any commit
# becomes `docker pull …:c-<sha>` without a
# release ceremony.
# anything else → safety net; shouldn't fire given the `on:` # anything else → safety net; shouldn't fire given the `on:`
# config above (dev was dropped). Tag :dev to # config above. Tag :dev to surface the
# surface the unexpected run in the registry. # unexpected run in the registry.
SHORT_SHA="${GITHUB_SHA:0:7}"
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}" TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
elif [ "${GITHUB_REF##*/}" = "main" ]; then elif [ "${GITHUB_REF##*/}" = "main" ]; then
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest,git.fabledsword.com/bvandeusen/fabledcurator:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
else else
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
fi fi
@@ -286,13 +294,15 @@ jobs:
id: tag id: tag
run: | run: |
# Mirrors build-web's three-shape logic (tag-push / main-push / # Mirrors build-web's three-shape logic (tag-push / main-push /
# safety-net dev). The -ml image follows the same release cadence # safety-net dev) including the per-commit :c-<short_sha> tag
# as the web image. # on main-push per the family release-posture rule. The -ml
# image follows the same release cadence as the web image.
SHORT_SHA="${GITHUB_SHA:0:7}"
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}" TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
elif [ "${GITHUB_REF##*/}" = "main" ]; then elif [ "${GITHUB_REF##*/}" = "main" ]; then
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest,git.fabledsword.com/bvandeusen/fabledcurator-ml:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
else else
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
fi fi
@@ -0,0 +1,190 @@
"""collapse-sidecar-synthetic: repoint Posts/ImageProvenance/DownloadEvents
from `sidecar:<platform>:<slug>` synthetic Source anchors onto the real
Source for the same (artist, platform) when one exists, then delete the
synthetic.
Revision ID: 0028
Revises: 0027
Create Date: 2026-05-31
Background: alembic 0022 (2026-05-26) consolidated the old per-post-URL
Source rows into one canonical Source per (artist, platform). When NO
real campaign URL was salvageable among the candidates, it rewrote the
canonical row to url='sidecar:<platform>:<slug>' enabled=false as a
disabled anchor for any Posts already attached.
That was fine while it was the only Source for that artist+platform.
But: the unique constraint on Source is (artist_id, platform, url), not
(artist_id, platform). When the operator later added the real
subscription via the UI / extension / etc., a SECOND row landed —
the real one — with id > the synthetic. Both coexisted.
Two follow-on problems surfaced 2026-05-31:
1. The Subscriptions UI listed both rows. The synthetic was disabled
so the scheduler never polled it, but it looked like a phantom
subscription. (Fixed in same commit by SourceService.list filter.)
2. importer._source_for_sidecar picked Source by `ORDER BY id ASC
LIMIT 1`, so EVERY gallery-dl download since the real Source was
added attached its Post to the SYNTHETIC anchor, not the real
Source. (Fixed in same commit by preferring non-sidecar URLs.)
This migration is the data half of the cleanup: for every (artist,
platform) with both a synthetic AND a real Source, repoint the
synthetic's children (Posts, ImageProvenance, DownloadEvents) onto the
real Source and delete the synthetic. Reuses the same epid/provenance
collision dance from alembic 0022 because the same uniqueness
constraints fire row-by-row during bulk UPDATEs.
Lone synthetic anchors — those where no real Source for the same
(artist, platform) exists (e.g., filesystem-imported artist with no
subscription added) — are LEFT INTACT. They anchor real imported
content; deleting them would CASCADE-delete the Posts the operator
imported. The SourceService.list filter hides them from the UI; the
operator can delete them by hand if they want the underlying imports
gone.
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import text
revision: str = "0028"
down_revision: Union[str, None] = "0027"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# Find (artist_id, platform) groups where BOTH a sidecar synthetic
# and at least one real Source exist.
groups = conn.execute(text("""
SELECT artist_id, platform
FROM source
GROUP BY artist_id, platform
HAVING bool_or(url LIKE 'sidecar:%')
AND bool_or(url NOT LIKE 'sidecar:%')
""")).fetchall()
for artist_id, platform in groups:
rows = conn.execute(
text("""
SELECT id, url FROM source
WHERE artist_id = :a AND platform = :p
ORDER BY id ASC
"""),
{"a": artist_id, "p": platform},
).fetchall()
synthetic_ids = [sid for sid, url in rows if url.startswith("sidecar:")]
real_rows = [(sid, url) for sid, url in rows if not url.startswith("sidecar:")]
if not synthetic_ids or not real_rows:
continue # belt+suspenders; the GROUP BY already filtered
# Canonical real: lowest-id non-sidecar Source.
canonical_id = real_rows[0][0]
# STEP A: PRE-merge Post collisions on (canonical, external_post_id).
# Mirror alembic 0022's pre-merge logic — when synth has Post X
# epid=N and real has Post Y epid=N, the bulk UPDATE below would
# trip uq_post_source_external_id row-by-row. Group all Posts
# under (canonical + synthetics) by epid; for any group >1,
# pick a keep (prefer one already under canonical, else lowest
# id) and merge the rest into it.
all_posts = conn.execute(
text("""
SELECT external_post_id, id, source_id
FROM post
WHERE source_id = :canonical OR source_id = ANY(:synths)
ORDER BY external_post_id, id
"""),
{"canonical": canonical_id, "synths": synthetic_ids},
).fetchall()
by_epid: dict = {}
for epid, post_id, src_id in all_posts:
by_epid.setdefault(epid, []).append((post_id, src_id))
for _epid, posts in by_epid.items():
if len(posts) <= 1:
continue
canonical_side = [p for p in posts if p[1] == canonical_id]
keep_id = canonical_side[0][0] if canonical_side else posts[0][0]
drop_ids = [p[0] for p in posts if p[0] != keep_id]
for drop_id in drop_ids:
# Pre-delete image_provenance rows under drop_ whose
# image_record_id already has provenance under keep —
# avoids tripping uq_image_provenance_image_post (0021)
# row-by-row during the repoint UPDATE.
conn.execute(
text("""
DELETE FROM image_provenance
WHERE post_id = :drop_
AND image_record_id IN (
SELECT image_record_id FROM image_provenance
WHERE post_id = :keep
)
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE image_provenance SET post_id = :keep
WHERE post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE image_record SET primary_post_id = :keep
WHERE primary_post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("DELETE FROM post WHERE id = :drop_"),
{"drop_": drop_id},
)
# STEP B: Bulk reparent the remaining Posts off the synthetics.
conn.execute(
text("""
UPDATE post SET source_id = :canonical
WHERE source_id = ANY(:synths)
"""),
{"canonical": canonical_id, "synths": synthetic_ids},
)
# STEP C: Reparent ImageProvenance.source_id (denormalized FK;
# no UNIQUE on source_id, safe bulk).
conn.execute(
text("""
UPDATE image_provenance SET source_id = :canonical
WHERE source_id = ANY(:synths)
"""),
{"canonical": canonical_id, "synths": synthetic_ids},
)
# STEP D: Reparent any DownloadEvent.source_id. Synthetics are
# enabled=false so the scheduler never created events for them;
# this is belt+suspenders for any rows planted by manual force
# or older code paths.
conn.execute(
text("""
UPDATE download_event SET source_id = :canonical
WHERE source_id = ANY(:synths)
"""),
{"canonical": canonical_id, "synths": synthetic_ids},
)
# STEP E: Drop the now-empty synthetics.
conn.execute(
text("DELETE FROM source WHERE id = ANY(:synths)"),
{"synths": synthetic_ids},
)
def downgrade() -> None:
# Lossy migration — synthetic Sources deleted, Posts repointed and
# potentially merged. No safe downgrade.
pass
+18
View File
@@ -57,6 +57,24 @@ def _sha256(path: Path) -> str:
return h.hexdigest() return h.hexdigest()
@extension_bp.route("/probe", methods=["GET"])
async def probe_source():
"""Read-only resolution of a creator-page URL: tells the extension
whether this URL is already a Source, is for an Artist that exists
but with a different URL, is brand new, or doesn't match any known
platform pattern. Drives the content-script chip's color/copy
BEFORE the operator clicks, so the button can show 'already added'
without requiring an add-attempt."""
url = (request.args.get("url") or "").strip()
if not url:
return _bad("invalid_body", detail="url query parameter is required")
async with get_session() as session:
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
result = await ExtensionService(session).probe(url)
return jsonify(result)
@extension_bp.route("/quick-add-source", methods=["POST"]) @extension_bp.route("/quick-add-source", methods=["POST"])
async def quick_add_source(): async def quick_add_source():
body = await request.get_json(silent=True) body = await request.get_json(silent=True)
+61
View File
@@ -86,6 +86,67 @@ class ExtensionService:
"created_artist": created_artist, "created_artist": created_artist,
} }
async def probe(self, url: str) -> dict:
"""Read-only resolution of a creator-page URL against the FC DB.
Returns one of:
- {state: 'unknown_platform'} — URL didn't match any
platform's strict artist-page pattern
- {state: 'new', platform, slug} — would create both
artist and source on quick-add
- {state: 'artist_match', platform, slug, artist}
— artist exists, this
exact URL isn't a Source yet (collapses the sidecar-synthetic
case too — the synthetic anchor counts as an existing artist
row but not as a pollable Source for this URL)
- {state: 'source_match', platform, slug, artist, source}
— exact (artist, platform,
url) Source already exists
Side-effect-free: two SELECTs at most.
"""
try:
platform, raw_slug = self._derive(url)
except (UnknownPlatformError, InvalidUrlError):
return {"state": "unknown_platform"}
slug = slugify(raw_slug)
artist = (await self.session.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if artist is None:
return {"state": "new", "platform": platform, "slug": slug}
artist_payload = {"id": artist.id, "name": artist.name, "slug": artist.slug}
source = (await self.session.execute(
select(Source).where(
Source.artist_id == artist.id,
Source.platform == platform,
Source.url == url,
)
)).scalar_one_or_none()
if source is None:
return {
"state": "artist_match",
"platform": platform,
"slug": slug,
"artist": artist_payload,
}
return {
"state": "source_match",
"platform": platform,
"slug": slug,
"artist": artist_payload,
"source": {
"id": source.id,
"artist_id": source.artist_id,
"platform": source.platform,
"url": source.url,
"enabled": source.enabled,
},
}
def _derive(self, url: str) -> tuple[str, str]: def _derive(self, url: str) -> tuple[str, str]:
if not isinstance(url, str) or not url.strip(): if not isinstance(url, str) or not url.strip():
raise InvalidUrlError("url is empty") raise InvalidUrlError("url is empty")
+21 -8
View File
@@ -42,13 +42,16 @@ class ErrorType(StrEnum):
UNKNOWN_ERROR = "unknown_error" UNKNOWN_ERROR = "unknown_error"
# Pinned to download_source's Celery soft_time_limit (900s, see # 30 seconds shy of download_source's Celery soft_time_limit (900s, see
# tasks/download.py:32). Anything larger and Celery kills the task before # tasks/download.py:32). subprocess.run MUST raise TimeoutExpired before
# subprocess.run can raise TimeoutExpired — leaving the DownloadEvent # Celery raises SoftTimeLimitExceeded — otherwise Celery wins the race,
# stranded for the recovery sweep instead of capturing a clean timeout # SIGKILLs the worker, in-memory stdout/stderr is lost, and the
# error. Per-source bumps live in source.config_overrides for legitimately # DownloadEvent ends up empty-logged with "stranded by recovery sweep"
# long syncs. Operator-confirmed 2026-05-30 (~40-min hang investigation). # 18 minutes later (operator-flagged 2026-05-31, Knuxy event #38275).
_DEFAULT_GDL_TIMEOUT_SECONDS = 900 # The 30s buffer absorbs scheduler jitter / GC pauses without making
# legitimately-long-running syncs timeout-friendlier. Per-source bumps
# still live in source.config_overrides for legitimately long syncs.
_DEFAULT_GDL_TIMEOUT_SECONDS = 870
@dataclass @dataclass
@@ -369,7 +372,17 @@ class GalleryDLService:
if return_code in (1, 4) and (skip_line_count > 0 or has_skip_text) and not has_actual_error: if return_code in (1, 4) and (skip_line_count > 0 or has_skip_text) and not has_actual_error:
return ErrorType.NO_NEW_CONTENT, "No new content to download" return ErrorType.NO_NEW_CONTENT, "No new content to download"
if return_code in (1, 4) and not has_actual_error: # Tier-gated classification used to require `return_code in (1, 4)`,
# which silently fell through to UNKNOWN_ERROR when gallery-dl
# returned a different exit code for mixed-failure runs (e.g.
# paywall warnings + a missing yt-dlp dep flipping the exit bits).
# The artist then surfaced as "needs attention" purely because a
# paywall blocked posts the operator wasn't paying to see —
# operator-flagged 2026-05-31. Now: if no source-level error
# category fired AND tier-gated warnings are present, classify
# as TIER_LIMITED regardless of return code. Same priority order
# as before (auth/rate/access/not_found/network/http still win).
if not has_actual_error:
tier_gated_lines = [ tier_gated_lines = [
line for line in combined.split("\n") line for line in combined.split("\n")
if "][warning]" in line and "not allowed to view post" in line if "][warning]" in line and "not allowed to view post" in line
+34 -14
View File
@@ -261,24 +261,44 @@ class Importer:
def _source_for_sidecar( def _source_for_sidecar(
self, *, artist_id: int, platform: str, artist_slug: str, self, *, artist_id: int, platform: str, artist_slug: str,
) -> Source: ) -> Source:
"""Filesystem-import sidecar Source resolver. """Sidecar-import Source resolver. Used by both filesystem imports
and gallery-dl downloads (both write sidecar JSON, both flow through
_apply_sidecar / _capture_attachment).
Source represents a subscription feed (one per artist+platform — the Source represents a subscription feed (one per artist+platform — the
gallery-dl URL polled by the FC-3 downloader). The filesystem importer URL polled by the FC-3 downloader). The filesystem importer used to
used to call _find_or_create_source(url=sd.post_url), which created call _find_or_create_source(url=sd.post_url), creating one Source
one Source row per post URL — 100s of junk Sources per artist, all row per post URL — 100s of junk Sources per artist, all with
with enabled=True, polluting the artist detail page and tricking the enabled=True, polluting the artist detail page and tricking the
subscription checker into trying to poll patreon post URLs as feeds. subscription checker into trying to poll patreon post URLs as feeds.
Operator-flagged 2026-05-26. Operator-flagged 2026-05-26; consolidated via alembic 0022.
New behaviour: if any Source row exists for (artist_id, platform), Resolution order: prefer a real (non-sidecar) Source over a
reuse it regardless of its URL — the artist's real subscription Source synthetic anchor. When alembic 0022 ran, it may have rewritten
(created by the downloader / extension / UI) is the canonical per-post Sources into `sidecar:<platform>:<slug>` synthetic
attachment point for filesystem-imported posts. If none exists, create anchors. If the operator later added the real subscription, both
ONE synthetic anchor with url='sidecar:<platform>:<artist_slug>' and rows now coexist. A naive `ORDER BY id ASC LIMIT 1` lookup would
enabled=False (so the subscription checker doesn't poll it). pick the older synthetic and silently attach every gallery-dl
download to the wrong Source — operator-flagged 2026-05-31 after
the Subscriptions UI surfaced the phantom anchors. Pick the real
one when one exists; fall back to the synthetic; only create a
new synthetic when nothing exists for (artist, platform).
""" """
stmt = ( real_stmt = (
select(Source)
.where(
Source.artist_id == artist_id,
Source.platform == platform,
~Source.url.like("sidecar:%"),
)
.order_by(Source.id.asc())
.limit(1)
)
real = self.session.execute(real_stmt).scalar_one_or_none()
if real is not None:
return real
any_stmt = (
select(Source) select(Source)
.where( .where(
Source.artist_id == artist_id, Source.artist_id == artist_id,
@@ -288,7 +308,7 @@ class Importer:
.limit(1) .limit(1)
) )
return self._get_or_create( return self._get_or_create(
stmt, any_stmt,
lambda: Source( lambda: Source(
artist_id=artist_id, artist_id=artist_id,
platform=platform, platform=platform,
+8
View File
@@ -151,10 +151,18 @@ class SourceService:
async def list( async def list(
self, artist_id: int | None = None, failing: bool = False, self, artist_id: int | None = None, failing: bool = False,
include_synthetic: bool = False,
) -> list[SourceRecord]: ) -> list[SourceRecord]:
stmt = select(Source, Artist).join(Artist, Artist.id == Source.artist_id) stmt = select(Source, Artist).join(Artist, Artist.id == Source.artist_id)
if artist_id is not None: if artist_id is not None:
stmt = stmt.where(Source.artist_id == artist_id) stmt = stmt.where(Source.artist_id == artist_id)
if not include_synthetic:
# Filesystem-import sidecar anchors (importer._source_for_sidecar)
# have url='sidecar:<platform>:<slug>' and exist only to give
# imported Posts a NOT-NULL Source FK. They aren't pollable
# feeds; the Subscriptions UI used to render them as phantom
# subscriptions. Hide by default.
stmt = stmt.where(~Source.url.like("sidecar:%"))
if failing: if failing:
# Worst-first so the rollup card surfaces the loudest failures. # Worst-first so the rollup card surfaces the loudest failures.
stmt = stmt.where(Source.consecutive_failures > 0).order_by( stmt = stmt.where(Source.consecutive_failures > 0).order_by(
+23
View File
@@ -259,6 +259,29 @@ browser.runtime.onMessage.addListener(async (msg) => {
return { error: e.message }; return { error: e.message };
} }
case 'PROBE_SOURCE':
try {
return await api.probeSource(msg.url);
} catch (e) {
return { error: e.message };
}
case 'OPEN_ARTIST_PAGE': {
// apiUrl is configured with the /api suffix (see
// options/options.html placeholder); the SPA artist route is
// /artist/:slug, served from the same origin. Strip /api so the
// browser-level URL hits the Vue router, not the JSON API.
const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
const slug = encodeURIComponent(msg.slug || '');
if (!base || !slug) return { error: 'apiUrl or slug missing' };
try {
await browser.tabs.create({ url: `${base}/artist/${slug}` });
return { success: true };
} catch (e) {
return { error: e.message };
}
}
default: default:
return { error: `Unknown message type: ${msg.type}` }; return { error: `Unknown message type: ${msg.type}` };
} }
+16 -1
View File
@@ -5,11 +5,26 @@
background: rgb(20, 23, 26); color: rgb(244, 186, 122); background: rgb(20, 23, 26); color: rgb(244, 186, 122);
font: 500 14px/1.2 system-ui, sans-serif; font: 500 14px/1.2 system-ui, sans-serif;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); cursor: pointer; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); cursor: pointer;
transition: transform 100ms ease; transition: transform 100ms ease, background 150ms ease, color 150ms ease;
} }
.fc-add-source-btn:hover { transform: translateY(-1px); } .fc-add-source-btn:hover { transform: translateY(-1px); }
.fc-add-source-btn:disabled { opacity: 0.6; cursor: wait; } .fc-add-source-btn:disabled { opacity: 0.6; cursor: wait; }
/* state colors map to the FC palette: parchment-on-slate base,
accent-orange for new, sage for already-subscribed, amber-warning for
artist-exists-but-source-missing. All readable on the dark base. */
.fc-add-source-btn--new {
background: rgb(20, 23, 26); color: rgb(244, 186, 122);
}
.fc-add-source-btn--artist-match {
background: rgb(28, 23, 16); color: rgb(255, 200, 120);
border: 1px solid rgb(180, 130, 60);
}
.fc-add-source-btn--source-match {
background: rgb(18, 28, 20); color: rgb(140, 220, 160);
border: 1px solid rgb(80, 160, 100);
}
.fc-toast { .fc-toast {
all: revert; all: revert;
position: fixed; bottom: 84px; right: 24px; z-index: 2147483647; position: fixed; bottom: 84px; right: 24px; z-index: 2147483647;
+93 -11
View File
@@ -2,6 +2,10 @@
if (window.__fc_addsource_injected) return; if (window.__fc_addsource_injected) return;
window.__fc_addsource_injected = true; window.__fc_addsource_injected = true;
// Cached probe result for the current URL so click-handlers know which
// action to dispatch without round-tripping again.
let currentProbe = null;
evaluate(); evaluate();
const reEval = () => evaluate(); const reEval = () => evaluate();
@@ -9,38 +13,116 @@
const origPush = history.pushState; const origPush = history.pushState;
history.pushState = function () { origPush.apply(this, arguments); reEval(); }; history.pushState = function () { origPush.apply(this, arguments); reEval(); };
function evaluate() { async function evaluate() {
const platform = getPlatformFromUrl(window.location.href); const url = window.location.href;
const onArtist = platform && isArtistPage(window.location.href, platform); const platform = getPlatformFromUrl(url);
let btn = document.getElementById('fc-add-source-btn'); const onArtist = platform && isArtistPage(url, platform);
if (onArtist && !btn) injectButton(); const btn = document.getElementById('fc-add-source-btn');
else if (!onArtist && btn) btn.remove(); if (!onArtist) {
if (btn) btn.remove();
currentProbe = null;
return;
}
// On artist pages, ask the backend what state the URL is in BEFORE
// injecting the button — so the chip can render the right state on
// first paint instead of flashing the generic "Add" copy and
// updating afterwards.
let probe;
try {
probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url });
} catch (e) {
probe = { error: e?.message || 'probe failed' };
}
currentProbe = probe;
if (probe?.state === 'unknown_platform') {
if (btn) btn.remove();
return;
}
renderButton(probe);
} }
function injectButton() { function renderButton(probe) {
const btn = document.createElement('button'); let btn = document.getElementById('fc-add-source-btn');
if (!btn) {
btn = document.createElement('button');
btn.id = 'fc-add-source-btn'; btn.id = 'fc-add-source-btn';
btn.className = 'fc-add-source-btn';
btn.textContent = '+ Add to FabledCurator';
btn.addEventListener('click', onClick); btn.addEventListener('click', onClick);
document.body.appendChild(btn); document.body.appendChild(btn);
} }
// Reset state classes so re-renders (SPA navigation) don't stack.
btn.className = 'fc-add-source-btn';
btn.classList.add(`fc-add-source-btn--${stateModifier(probe)}`);
btn.textContent = labelFor(probe);
btn.disabled = false;
}
function stateModifier(probe) {
if (!probe || probe.error) return 'new';
return ({
source_match: 'source-match',
artist_match: 'artist-match',
new: 'new',
})[probe.state] || 'new';
}
function labelFor(probe) {
if (!probe || probe.error) return '+ Add to FabledCurator';
const platformName = platformDisplayName(probe.platform);
const artistName = probe.artist?.name;
switch (probe.state) {
case 'source_match':
return `✓ In FabledCurator · ${platformName}`;
case 'artist_match':
return `+ Add ${platformName} source to ${artistName || 'artist'}`;
case 'new':
default:
return '+ Add to FabledCurator';
}
}
function platformDisplayName(key) {
return PLATFORMS[key]?.name || key || '';
}
async function onClick() { async function onClick() {
const btn = document.getElementById('fc-add-source-btn'); const btn = document.getElementById('fc-add-source-btn');
if (!btn) return;
btn.disabled = true; btn.disabled = true;
const original = btn.textContent; const original = btn.textContent;
const probe = currentProbe;
if (probe?.state === 'source_match') {
btn.textContent = 'Opening…';
try {
const r = await browser.runtime.sendMessage({
type: 'OPEN_ARTIST_PAGE',
slug: probe.artist?.slug,
});
if (r?.error) showToast(`Error: ${r.error}`, 'error');
} catch (e) {
showToast(`Error: ${e.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = original;
}
return;
}
btn.textContent = 'Adding…'; btn.textContent = 'Adding…';
try { try {
const r = await browser.runtime.sendMessage({ const r = await browser.runtime.sendMessage({
type: 'ADD_AS_SOURCE', type: 'ADD_AS_SOURCE',
url: window.location.href, url: window.location.href,
}); });
if (r.error) { if (r?.error) {
showToast(`Error: ${r.error}`, 'error'); showToast(`Error: ${r.error}`, 'error');
} else { } else {
const verb = r.created_source ? 'Added' : 'Already a source for'; const verb = r.created_source ? 'Added' : 'Already a source for';
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success'); showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success');
// Re-probe so the chip flips green without waiting for the next
// navigation.
evaluate();
return;
} }
} catch (e) { } catch (e) {
showToast(`Error: ${e.message}`, 'error'); showToast(`Error: ${e.message}`, 'error');
+6
View File
@@ -83,6 +83,12 @@ class FabledCuratorAPI {
quickAddSource(url) { quickAddSource(url) {
return this.request('POST', '/extension/quick-add-source', { url }); return this.request('POST', '/extension/quick-add-source', { url });
} }
probeSource(url) {
// Read-only existence check. Drives the content-script chip's
// color/copy BEFORE the operator clicks Add.
const qs = new URLSearchParams({ url }).toString();
return this.request('GET', `/extension/probe?${qs}`);
}
// Connection test = the cheapest read with auth. // Connection test = the cheapest read with auth.
testConnection() { testConnection() {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "FabledCurator", "name": "FabledCurator",
"version": "1.0.5", "version": "1.0.6",
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.", "description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
"browser_specific_settings": { "browser_specific_settings": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "fabledcurator-extension", "name": "fabledcurator-extension",
"version": "1.0.5", "version": "1.0.6",
"private": true, "private": true,
"description": "Firefox extension for FabledCurator", "description": "Firefox extension for FabledCurator",
"scripts": { "scripts": {
+5
View File
@@ -22,6 +22,11 @@ imagehash>=4.3,<4.4
# Gallery-dl wrapper (lands in FC-3) # Gallery-dl wrapper (lands in FC-3)
gallery-dl>=1.32,<1.33 gallery-dl>=1.32,<1.33
# Video extractor backend for gallery-dl. Without it, every video post
# attachment fails with `[downloader.ytdl][error] Cannot import yt-dlp`
# → `[download][error] Failed to download NN_video.mp4`. Operator-flagged
# 2026-05-31 after Patreon video posts produced empty downloads.
yt-dlp>=2025.1
# Utilities # Utilities
python-dotenv>=1.2,<2.0 python-dotenv>=1.2,<2.0
+108
View File
@@ -135,6 +135,114 @@ async def test_quick_add_source_wrong_key_401(client, ext_key):
assert resp.status_code == 401 assert resp.status_code == 401
# --- /api/extension/probe ---------------------------------------------
@pytest.mark.asyncio
async def test_probe_returns_new_when_nothing_exists(client, ext_key):
resp = await client.get(
"/api/extension/probe",
query_string={"url": "https://www.patreon.com/freshcreator"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["state"] == "new"
assert body["platform"] == "patreon"
assert body["slug"] == "freshcreator"
@pytest.mark.asyncio
async def test_probe_returns_source_match_for_already_added(client, ext_key, db):
artist = Artist(name="Alice", slug="alice", is_subscription=True)
db.add(artist)
await db.flush()
src = Source(
artist_id=artist.id, platform="patreon",
url="https://www.patreon.com/alice", enabled=True, config_overrides={},
)
db.add(src)
await db.commit()
resp = await client.get(
"/api/extension/probe",
query_string={"url": "https://www.patreon.com/alice"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["state"] == "source_match"
assert body["artist"]["slug"] == "alice"
assert body["source"]["url"] == "https://www.patreon.com/alice"
assert body["source"]["platform"] == "patreon"
@pytest.mark.asyncio
async def test_probe_returns_artist_match_when_only_synthetic_anchor_exists(
client, ext_key, db,
):
"""Filesystem-imported artist with only a sidecar synthetic Source
for the (artist, platform) — the URL the operator's browsing isn't
yet a real Source. The probe should collapse this into artist_match
so the chip says '+ Add Patreon source to Dymkens' rather than
'+ Add to FabledCurator' (which would re-create the artist)."""
artist = Artist(name="Dymkens", slug="dymkens", is_subscription=False)
db.add(artist)
await db.flush()
synthetic = Source(
artist_id=artist.id, platform="patreon",
url="sidecar:patreon:dymkens", enabled=False, config_overrides={},
)
db.add(synthetic)
await db.commit()
resp = await client.get(
"/api/extension/probe",
query_string={"url": "https://www.patreon.com/dymkens"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["state"] == "artist_match"
assert body["artist"]["slug"] == "dymkens"
assert "source" not in body
@pytest.mark.asyncio
async def test_probe_returns_unknown_platform_for_non_artist_url(client, ext_key):
"""A patreon URL that isn't an artist page (e.g. /home, /posts/N)
shouldn't trigger the button. Sentinel 'unknown_platform' state
tells the content script to skip injection."""
resp = await client.get(
"/api/extension/probe",
query_string={"url": "https://www.patreon.com/posts/12345"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["state"] == "unknown_platform"
@pytest.mark.asyncio
async def test_probe_missing_key_401(client):
resp = await client.get(
"/api/extension/probe",
query_string={"url": "https://www.patreon.com/maewix"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_probe_missing_url_400(client, ext_key):
resp = await client.get(
"/api/extension/probe",
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "invalid_body"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_quick_add_source_missing_body_400(client, ext_key): async def test_quick_add_source_missing_body_400(client, ext_key):
resp = await client.post( resp = await client.post(
+52
View File
@@ -164,6 +164,58 @@ async def test_validation_quarantines_truncated_files(tmp_path, monkeypatch):
assert result.error_type == ErrorType.VALIDATION_FAILED assert result.error_type == ErrorType.VALIDATION_FAILED
@pytest.mark.asyncio
async def test_download_tier_limited_classified_as_success(gdl, monkeypatch):
"""Pure tier-gated stderr (paywall warnings only, no source-level
errors). Should be TIER_LIMITED with success=True so the
DownloadEvent doesn't surface as 'needs attention' for an operator
who's just not paying the top tier. Pre-fix: this fell through to
UNKNOWN_ERROR when the return code wasn't 1 or 4."""
stderr = (
"[patreon][warning] Not allowed to view post 159421258\n"
"[patreon][warning] Not allowed to view post 159247879\n"
"[patreon][warning] Not allowed to view post 158111214\n"
)
# Use a non-(1,4) return code to prove the gate widened.
monkeypatch.setattr(
"backend.app.services.gallery_dl.subprocess.run",
lambda *a, **k: _proc(stdout="", stderr=stderr, returncode=128),
)
result = await gdl.download(
url="https://patreon.com/alice", artist_slug="alice", platform="patreon",
source_config=SourceConfig(),
)
assert result.error_type == ErrorType.TIER_LIMITED
assert result.success is True
assert "3 posts" in (result.error_message or "")
@pytest.mark.asyncio
async def test_download_tier_limited_with_per_item_video_failures(gdl, monkeypatch):
"""Real operator log shape (2026-05-31): hundreds of paywall warnings
+ a handful of per-item video errors caused by missing yt-dlp. The
video errors are classified as per-item (not source-level), so the
classifier should still pick TIER_LIMITED. Once yt-dlp is installed
the video errors stop firing; until then, the source-health doesn't
flap to 'needs attention' over paywalled content."""
stderr = (
"[patreon][warning] Not allowed to view post 159421258\n"
"[patreon][warning] Not allowed to view post 159247879\n"
"[downloader.ytdl][error] Cannot import yt-dlp or youtube-dl\n"
"[download][error] Failed to download 02_video.mp4\n"
)
monkeypatch.setattr(
"backend.app.services.gallery_dl.subprocess.run",
lambda *a, **k: _proc(stdout="", stderr=stderr, returncode=1),
)
result = await gdl.download(
url="https://patreon.com/alice", artist_slug="alice", platform="patreon",
source_config=SourceConfig(),
)
assert result.error_type == ErrorType.TIER_LIMITED
assert result.success is True
def test_compute_run_stats(gdl): def test_compute_run_stats(gdl):
stats = gdl._compute_run_stats( stats = gdl._compute_run_stats(
return_code=0, return_code=0,
+33
View File
@@ -210,3 +210,36 @@ def test_source_for_sidecar_distinct_platforms_distinct_anchors(
assert p.id != x.id assert p.id != x.id
assert p.platform == "patreon" assert p.platform == "patreon"
assert x.platform == "pixiv" assert x.platform == "pixiv"
def test_source_for_sidecar_prefers_real_over_synthetic_when_both_exist(
importer, artist_row, db_sync,
):
"""When BOTH a synthetic anchor AND a real Source exist for the same
(artist, platform), the resolver must return the REAL one. This is the
fix for the 2026-05-31 phantom-subscription bug: alembic 0022 had
rewritten an older per-post Source row into a sidecar synthetic, and
the operator later added the real subscription. The old `ORDER BY
id ASC LIMIT 1` lookup picked the older synthetic (lower id),
silently attaching every gallery-dl download to the wrong Source.
"""
synthetic = Source(
artist_id=artist_row.id, platform="patreon",
url=f"sidecar:patreon:{artist_row.slug}", enabled=False,
)
db_sync.add(synthetic)
db_sync.flush()
real = Source(
artist_id=artist_row.id, platform="patreon",
url="https://www.patreon.com/testartist", enabled=True,
)
db_sync.add(real)
db_sync.flush()
assert synthetic.id < real.id # ordering precondition
resolved = importer._source_for_sidecar(
artist_id=artist_row.id, platform="patreon",
artist_slug=artist_row.slug,
)
assert resolved.id == real.id
assert resolved.url == "https://www.patreon.com/testartist"
+37 -1
View File
@@ -1,7 +1,7 @@
import pytest import pytest
from sqlalchemy import select from sqlalchemy import select
from backend.app.models import Artist from backend.app.models import Artist, Source
from backend.app.services.source_service import ( from backend.app.services.source_service import (
KNOWN_PLATFORMS, KNOWN_PLATFORMS,
ArtistNotFoundError, ArtistNotFoundError,
@@ -133,3 +133,39 @@ async def test_update_changes_fields(db):
updated = await svc.update(rec.id, enabled=False, config_overrides={"videos": False}) updated = await svc.update(rec.id, enabled=False, config_overrides={"videos": False})
assert updated.enabled is False assert updated.enabled is False
assert updated.config_overrides == {"videos": False} assert updated.config_overrides == {"videos": False}
@pytest.mark.asyncio
async def test_list_hides_sidecar_synthetic_anchors(db):
"""Filesystem-import synthetic Sources (url='sidecar:<platform>:<slug>',
enabled=False — see importer._source_for_sidecar) used to leak into the
Subscriptions UI as phantom subscriptions because list() didn't filter
them. They aren't pollable feeds; hide by default."""
artist = await _artist(db, "Alice")
real = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice", enabled=True, config_overrides={},
)
synthetic = Source(
artist_id=artist.id, platform="patreon",
url="sidecar:patreon:alice", enabled=False, config_overrides={},
)
db.add_all([real, synthetic])
await db.commit()
svc = SourceService(db)
visible = await svc.list()
visible_urls = {s.url for s in visible}
assert "https://patreon.com/alice" in visible_urls
assert "sidecar:patreon:alice" not in visible_urls
# Same filter applies to the artist-scoped list path (the artist detail
# page hits /api/sources?artist_id=N).
artist_scoped = await svc.list(artist_id=artist.id)
assert {s.url for s in artist_scoped} == {"https://patreon.com/alice"}
# include_synthetic=True opts back in for admin tooling.
everything = await svc.list(include_synthetic=True)
assert {s.url for s in everything} >= {
"https://patreon.com/alice", "sidecar:patreon:alice",
}