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 @@ > hero thumbnail -
+