diff --git a/backend/app/services/download_backends.py b/backend/app/services/download_backends.py
index 082b92b..fab1bb5 100644
--- a/backend/app/services/download_backends.py
+++ b/backend/app/services/download_backends.py
@@ -25,13 +25,17 @@ from pathlib import Path
from .gallery_dl import DownloadResult, ErrorType
from .patreon_ingester import PatreonIngester
-from .patreon_resolver import resolve_campaign_id_for_source
+from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
# Platforms whose download + verify go through the native ingester rather than
# gallery-dl. gallery-dl still serves every other platform (subscribestar,
# hentaifoundry, discord, pixiv, deviantart) unchanged.
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon"})
+# Mirrors patreon_resolver._CAMPAIGNS_URL — surfaced in resolution-failure
+# messages so the operator sees the exact lookup endpoint that was hit.
+_CAMPAIGNS_API = "https://www.patreon.com/api/campaigns"
+
def uses_native_ingester(platform: str) -> bool:
"""True when `platform` is served by the native ingester (not gallery-dl).
@@ -93,15 +97,19 @@ async def _run_native_ingester(
ctx["url"], ctx["cookies_path"], overrides
)
if not campaign_id:
+ url = ctx["url"]
+ vanity = extract_vanity(url)
return (
DownloadResult(
success=False,
- url=ctx["url"],
+ url=url,
artist_slug=ctx["artist_slug"],
platform="patreon",
error_type=ErrorType.NOT_FOUND,
error_message=(
- "Could not resolve Patreon campaign id from the source URL "
+ f"Could not resolve Patreon campaign id. source_url={url!r}; "
+ f"vanity={vanity!r}; "
+ f"lookup=GET {_CAMPAIGNS_API}?filter[vanity]={vanity or ''} "
"(vanity lookup failed — cookies expired or creator moved?)"
),
),
@@ -172,9 +180,11 @@ async def preview_source(
url, cookies_path, config_overrides or {}
)
if not campaign_id:
+ vanity = extract_vanity(url)
return {
"error": (
- "Couldn't resolve the campaign id from the source URL "
+ f"Couldn't resolve the campaign id. source_url={url!r}; "
+ f"vanity={vanity!r}; lookup=GET {_CAMPAIGNS_API}?filter[vanity]={vanity or ''} "
"(cookies expired, or the creator moved/renamed?)."
)
}
diff --git a/backend/app/services/download_service.py b/backend/app/services/download_service.py
index 66e960b..0fc04e6 100644
--- a/backend/app/services/download_service.py
+++ b/backend/app/services/download_service.py
@@ -269,6 +269,11 @@ class DownloadService:
source_row = self.sync_session.get(Source, ctx["source_id"])
import_summary = {"attached": 0, "skipped": 0, "errors": 0}
+ # Archives detected but captured WITHOUT extracting any image (probe
+ # rejected / corrupt / missing extractor backend). Surfaced on the event
+ # so a post showing "no images" beside a zip is diagnosable (plan
+ # follow-up 2026-06-06 — the recurring archive-association report).
+ unextracted_archives: list[dict] = []
bytes_downloaded = 0
loop = asyncio.get_running_loop()
@@ -322,6 +327,17 @@ class DownloadService:
# mirror duplicate_hash cleanup so we don't keep two copies.
# Operator-flagged 2026-06-02 (Lustria OST zip).
import_summary["attached"] += 1
+ # An archive captured WITHOUT extracting any image carries a
+ # reason on result.error — record it so the event explains the
+ # "no images beside a zip" symptom instead of staying silent.
+ if result.error:
+ unextracted_archives.append(
+ {"file": path.name, "reason": result.error}
+ )
+ log.warning(
+ "archive captured unextracted (%s): %s",
+ path.name, result.error,
+ )
try:
bytes_downloaded += path.stat().st_size # noqa: ASYNC240
except OSError:
@@ -403,6 +419,10 @@ class DownloadService:
"duration_seconds": dl_result.duration_seconds,
"quarantined_paths": dl_result.quarantined_paths or None,
"import_summary": import_summary,
+ # Archives detected but captured without extracting an image — the
+ # recurring "post shows a zip but no images" report. Each entry is
+ # {file, reason}; None when every archive extracted cleanly.
+ "unextracted_archives": unextracted_archives or None,
}
await self._update_source_health(
source_id=ctx["source_id"], status=status, error_message=ev.error,
diff --git a/backend/app/services/importer.py b/backend/app/services/importer.py
index e1acc6e..c954bfe 100644
--- a/backend/app/services/importer.py
+++ b/backend/app/services/importer.py
@@ -430,13 +430,17 @@ class Importer:
self._capture_attachment(
source, post=post, artist=artist_use, resolved=True,
)
- return ImportResult(status="attached")
+ reason = f"archive probe rejected, captured unextracted: {probe.reason}"
+ log.warning("%s: %s", source.name, reason)
+ return ImportResult(status="attached", error=reason)
artist_use = artist if artist is not None else self._resolve_artist(source)
post = self._post_for_sidecar(source, artist_use)
member_ids: list[int] = []
+ member_total = 0
with extract_archive(source) as members:
for _name, member_path in members:
+ member_total += 1
if not is_supported(member_path):
continue # non-media preserved via the stored archive
res = self._import_media(
@@ -453,7 +457,19 @@ class Importer:
status="imported", image_id=member_ids[0],
member_image_ids=member_ids,
)
- return ImportResult(status="attached")
+ # No images landed — surface WHY so a post showing "no images" beside an
+ # archive is diagnosable instead of silent. Zero members usually means
+ # the extractor backend is missing/failed (unar for rar, py7zr for 7z)
+ # or the file is corrupt; non-zero-but-no-images means it held only
+ # non-media files.
+ reason = (
+ "archive extracted but held no supported image/video members"
+ if member_total
+ else "archive yielded no members (unsupported/corrupt, or the "
+ "extractor backend failed)"
+ )
+ log.warning("%s: %s", source.name, reason)
+ return ImportResult(status="attached", error=reason)
def _import_media(
self, source: Path, attribution_path: Path,
diff --git a/backend/app/services/patreon_ingester.py b/backend/app/services/patreon_ingester.py
index 5915da7..be2c973 100644
--- a/backend/app/services/patreon_ingester.py
+++ b/backend/app/services/patreon_ingester.py
@@ -45,7 +45,7 @@ from .patreon_client import (
PatreonDriftError,
)
from .patreon_downloader import PatreonDownloader
-from .patreon_resolver import resolve_campaign_id_for_source
+from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
__all__ = [
"DEAD_LETTER_THRESHOLD",
@@ -186,9 +186,11 @@ async def verify_patreon_credential(
"""
campaign_id, _ = await resolve_campaign_id_for_source(url, cookies_path, overrides)
if not campaign_id:
+ vanity = extract_vanity(url)
return None, (
- "Couldn't resolve the Patreon campaign id from the source URL — "
- "can't verify (cookies expired, or the creator moved/renamed?)."
+ f"Couldn't resolve the Patreon campaign id — can't verify. "
+ f"source_url={url!r}; vanity={vanity!r} "
+ "(cookies expired, or the creator moved/renamed?)."
)
client = PatreonClient(cookies_path)
loop = asyncio.get_running_loop()
diff --git a/backend/app/services/patreon_resolver.py b/backend/app/services/patreon_resolver.py
index fb29661..c8acd2c 100644
--- a/backend/app/services/patreon_resolver.py
+++ b/backend/app/services/patreon_resolver.py
@@ -41,6 +41,19 @@ _USER_AGENT = (
)
_TIMEOUT_SECONDS = 10.0
+# Fallback resolution: Patreon's `/api/campaigns?filter[vanity]=` lookup has
+# proven unreliable (returns empty `data` for creators that clearly exist —
+# operator-flagged 2026-06-06). gallery-dl never used that endpoint; it scrapes
+# the campaign id out of the creator page's bootstrap JSON. We do the same as a
+# fallback: fetch the creator page HTML and pull the first campaign id out of
+# any of these embeddings (ordered most- to least-specific).
+_PAGE_CAMPAIGN_ID_PATTERNS = (
+ re.compile(r'"id":\s*"(\d+)",\s*"type":\s*"campaign"'),
+ re.compile(r'"campaign":\s*\{\s*"data":\s*\{\s*"id":\s*"(\d+)"'),
+ re.compile(r"/api/campaigns/(\d+)"),
+ re.compile(r'"campaign_id":\s*"?(\d+)'),
+)
+
def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJar | None:
if not cookies_path or not os.path.isfile(cookies_path):
@@ -55,6 +68,15 @@ def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJa
def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
+ """Resolve a vanity to a campaign id: try the campaigns API first (cheap,
+ structured), then fall back to scraping the creator page (robust against the
+ API's empty-data failures). Returns None only when both miss."""
+ return _lookup_via_api(vanity, cookies_path) or _lookup_via_page(
+ vanity, cookies_path
+ )
+
+
+def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
jar = _load_cookie_jar(cookies_path)
headers = {
"User-Agent": _USER_AGENT,
@@ -102,6 +124,50 @@ def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
return campaign_id
+def _scrape_campaign_id(html: str) -> str | None:
+ """First campaign id found in creator-page HTML via the known embeddings."""
+ if not isinstance(html, str):
+ return None
+ for pat in _PAGE_CAMPAIGN_ID_PATTERNS:
+ m = pat.search(html)
+ if m:
+ return m.group(1)
+ return None
+
+
+def _lookup_via_page(vanity: str, cookies_path: str | None) -> str | None:
+ """Fallback: GET the creator page and scrape the campaign id from the page
+ bootstrap (gallery-dl's method). Tries both the bare and `/c/` vanity paths
+ Patreon redirects between. Never raises."""
+ jar = _load_cookie_jar(cookies_path)
+ headers = {"User-Agent": _USER_AGENT, "Accept": "text/html"}
+ for page_url in (
+ f"https://www.patreon.com/{vanity}",
+ f"https://www.patreon.com/c/{vanity}",
+ ):
+ try:
+ resp = requests.get(
+ page_url,
+ headers=headers,
+ cookies=jar,
+ timeout=_TIMEOUT_SECONDS,
+ allow_redirects=True,
+ )
+ except requests.RequestException as exc:
+ log.warning("Patreon creator-page fetch failed for %s: %s", page_url, exc)
+ continue
+ if resp.status_code != 200:
+ continue
+ campaign_id = _scrape_campaign_id(resp.text)
+ if campaign_id:
+ log.info(
+ "Resolved Patreon vanity=%s → campaign_id=%s via creator page",
+ vanity, campaign_id,
+ )
+ return campaign_id
+ return None
+
+
async def resolve_campaign_id(
vanity: str,
cookies_path: str | None,
diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py
index 60ffddd..bfdef07 100644
--- a/backend/app/services/tag_service.py
+++ b/backend/app/services/tag_service.py
@@ -17,15 +17,19 @@ log = logging.getLogger(__name__)
def normalize_tag_name(name: str) -> str:
- """Canonical tag form (#701): collapse whitespace + Title Case.
+ """Canonical tag form (#701): collapse whitespace + capitalize the first
+ letter of each word, PRESERVING the rest of the word.
Per-word capitalize (NOT str.title(), which mangles apostrophes:
- don't → Don'T). Lowercasing the word tail also folds ALL-CAPS input
- (HATSUNE MIKU → Hatsune Miku); acronym casing is sacrificed, an accepted
- Title-Case trade-off (operator-chosen 2026-06-06).
+ don't → Don'T). The word tail is left untouched so acronyms survive
+ (DC stays DC, NSFW stays NSFW) — operator-revised 2026-06-06: protecting
+ acronyms matters more than folding ALL-CAPS input. This MATCHES
+ ml/tag_name._title_word, so a tag suggested by the Camie tagger keeps the
+ exact casing the suggestion UI showed when it round-trips through the
+ create endpoint on Accept.
"""
return " ".join(
- w[:1].upper() + w[1:].lower() for w in (name or "").split()
+ w[:1].upper() + w[1:] for w in (name or "").split()
)
diff --git a/frontend/src/components/posts/PostCard.vue b/frontend/src/components/posts/PostCard.vue
index 29aa76d..cd1c2aa 100644
--- a/frontend/src/components/posts/PostCard.vue
+++ b/frontend/src/components/posts/PostCard.vue
@@ -34,7 +34,10 @@
>
-