Compare commits
6
Commits
0397642b21
...
ext-1.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
667b05f14e | ||
|
|
8de7ccd07d | ||
|
|
d65f0b2091 | ||
|
|
856e9104b4 | ||
|
|
66f19d67f5 | ||
|
|
6fc8ae3106 |
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,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,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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user