Merge pull request 'Tag-casing acronym fix, Patreon resolver hardening, archive diagnostics, post-card strip' (#74) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s

This commit was merged in pull request #74.
This commit is contained in:
2026-06-06 19:59:32 -04:00
12 changed files with 253 additions and 27 deletions
+14 -4
View File
@@ -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?)."
)
}
+20
View File
@@ -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,
+18 -2
View File
@@ -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,
+5 -3
View File
@@ -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()
+66
View File
@@ -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,
+9 -5
View File
@@ -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()
)
+32 -8
View File
@@ -34,7 +34,10 @@
>
<img :src="hero.thumbnail_url" alt="hero thumbnail" loading="lazy" />
</button>
<div v-if="rail.length || moreCount" class="fc-post-card__rail">
<div
v-if="rail.length || moreCount" class="fc-post-card__rail"
:style="{ '--fc-rail-cols': railCols }"
>
<button
v-for="t in rail" :key="t.image_id" type="button"
class="fc-post-card__rail-cell"
@@ -117,13 +120,29 @@ const totalImages = computed(() => images.value.length + (props.post.thumbnails_
const plainTitle = computed(() => toPlainText(props.post.post_title))
const hero = computed(() => images.value[0])
const rail = computed(() => images.value.slice(1, 4))
// The thumbnail strip spans the hero's full width (CSS grid, equal columns),
// rather than a fixed 3-cell cap. Show up to RAIL_MAX cells; when there are
// more images than fit, the last cell becomes a "+N" overflow tile so the
// count stays accurate.
const RAIL_MAX = 5
const serverMore = computed(() => props.post.thumbnails_more || 0)
const afterHero = computed(() => images.value.slice(1))
const hasOverflow = computed(
() => serverMore.value > 0 || afterHero.value.length > RAIL_MAX,
)
const rail = computed(() =>
hasOverflow.value
? afterHero.value.slice(0, RAIL_MAX - 1)
: afterHero.value.slice(0, RAIL_MAX),
)
const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
const moreCount = computed(() => {
const more = props.post.thumbnails_more || 0
const extraShown = Math.max(0, images.value.length - visibleCount.value)
return more + extraShown
if (!hasOverflow.value) return 0
return serverMore.value + Math.max(0, images.value.length - visibleCount.value)
})
// Columns = thumbnails shown plus the overflow tile, so the strip fills the
// hero's full width with equal cells.
const railCols = computed(() => rail.value.length + (moreCount.value > 0 ? 1 : 0))
const sortDateIso = computed(() => props.post.post_date || props.post.downloaded_at)
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
@@ -279,11 +298,16 @@ function formatBytes (n) {
.fc-post-card__hero:hover img,
.fc-post-card__rail-cell:hover img { transform: scale(1.03); filter: brightness(1.08); }
/* Equal columns spanning the hero's full width — cells stretch horizontally
(1fr) at a fixed height, so the strip always reaches the edge of the hero
regardless of how many thumbnails the post has. */
.fc-post-card__rail {
display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(var(--fc-rail-cols, 3), 1fr);
gap: 6px; margin-top: 6px;
}
.fc-post-card__rail-cell {
width: 80px; height: 80px;
width: 100%; height: 84px;
overflow: hidden; border-radius: 4px;
}
.fc-post-card__rail-cell img {
@@ -291,7 +315,7 @@ function formatBytes (n) {
object-fit: cover; display: block;
}
.fc-post-card__rail-more {
width: 80px; height: 80px;
width: 100%; height: 84px;
display: flex; align-items: center; justify-content: center;
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
border-radius: 4px;
+24
View File
@@ -40,6 +40,30 @@ describe('PostCard', () => {
expect(full.text()).not.toContain('Show more')
})
const thumbs = (n) =>
Array.from({ length: n }, (_, i) => ({ image_id: 100 + i, thumbnail_url: `/t${i}` }))
it('fills the rail to full width (5 cells, no overflow) for a 6-image post', () => {
const post = { ...BASE, description_plain: 'd', thumbnails: thumbs(6), thumbnails_more: 0 }
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
// hero + 5 rail thumbnails, no "+N" tile.
expect(w.findAll('.fc-post-card__rail-cell')).toHaveLength(5)
expect(w.find('.fc-post-card__rail-more').exists()).toBe(false)
// The grid spans the hero width via a column count == cells shown.
expect(w.find('.fc-post-card__rail').attributes('style')).toContain('--fc-rail-cols: 5')
})
it('reserves the last cell for a "+N" overflow tile when there are more images', () => {
const post = { ...BASE, description_plain: 'd', thumbnails: thumbs(6), thumbnails_more: 4 }
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
// 4 thumbnails + 1 overflow tile == 5 columns; tile counts every hidden image.
expect(w.findAll('.fc-post-card__rail-cell')).toHaveLength(4)
const more = w.find('.fc-post-card__rail-more')
expect(more.exists()).toBe(true)
expect(more.text()).toBe('+5')
expect(w.find('.fc-post-card__rail').attributes('style')).toContain('--fc-rail-cols: 5')
})
it('clicking an image opens the post-scoped modal (never expands the card)', async () => {
const pinia = freshPinia()
const modal = useModalStore()
+3 -2
View File
@@ -74,13 +74,14 @@ async def test_create_tag_with_kind_prefix(client):
@pytest.mark.asyncio
async def test_explicit_kind_overrides_prefix_parsing(client):
"""If caller passes explicit kind, don't re-parse the name —
colon and prefix stay literal. #701: still Title-Cased as a single word."""
colon and prefix stay literal. #701: first letter capitalized, tail
preserved (acronym-safe), so the 'S' in Saber survives."""
resp = await client.post(
"/api/tags", json={"name": "character:Saber", "kind": "general"}
)
assert resp.status_code == 201
body = await resp.get_json()
assert body["name"] == "Character:saber"
assert body["name"] == "Character:Saber"
assert body["kind"] == "general"
+35
View File
@@ -91,6 +91,41 @@ async def test_loads_cookies_file_when_present(tmp_path):
assert kwargs.get("cookies") is not None
@pytest.mark.asyncio
async def test_falls_back_to_creator_page_when_api_empty():
"""When the campaigns API returns empty data (its known failure mode), the
resolver scrapes the campaign id out of the creator page (gallery-dl's
method)."""
api_resp = MagicMock()
api_resp.status_code = 200
api_resp.json.return_value = {"data": []}
page_resp = MagicMock()
page_resp.status_code = 200
page_resp.text = (
'<html><script>window.patreon={"bootstrap":{"campaign":'
'{"data":{"id":"7654321","type":"campaign"}}}}</script></html>'
)
def _get(url, **kwargs):
return api_resp if "/api/campaigns" in url else page_resp
with patch(
"backend.app.services.patreon_resolver.requests.get", side_effect=_get
):
result = await resolve_campaign_id("alice", cookies_path=None)
assert result == "7654321"
def test_scrape_campaign_id_patterns():
from backend.app.services.patreon_resolver import _scrape_campaign_id
assert _scrape_campaign_id('foo /api/campaigns/424242 bar') == "424242"
assert _scrape_campaign_id('"campaign_id":"99"') == "99"
assert _scrape_campaign_id('{"id":"5","type":"campaign"}') == "5"
assert _scrape_campaign_id("no id here") is None
assert _scrape_campaign_id(None) is None
# -- resolve_campaign_id_for_source (shared by download + verify) ----------
+21 -2
View File
@@ -87,8 +87,9 @@ async def test_dry_run_projection_counts(db):
@pytest.mark.asyncio
async def test_live_merges_case_variants_and_repoints_images(db):
# lowercase vs sentence-case → both canonicalize to "Hatsune Miku" and fold.
a = await _raw_tag(db, "hatsune miku", TagKind.character)
b = await _raw_tag(db, "HATSUNE MIKU", TagKind.character)
b = await _raw_tag(db, "Hatsune miku", TagKind.character)
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await _link(db, i1, a.id)
await _link(db, i2, a.id)
@@ -125,7 +126,7 @@ async def test_live_merges_case_variants_and_repoints_images(db):
@pytest.mark.asyncio
async def test_idempotent_second_run_is_noop(db):
await _raw_tag(db, "kafka", TagKind.character)
await _raw_tag(db, "KAFKA", TagKind.character)
await _raw_tag(db, "Kafka", TagKind.character)
first = await normalize_existing_tags(db, dry_run=False)
assert first["groups_processed"] == 1
@@ -138,6 +139,24 @@ async def test_idempotent_second_run_is_noop(db):
assert again["renamed"] == 0
@pytest.mark.asyncio
async def test_acronym_casing_is_preserved_not_folded(db):
"""Acronym-preserving rule (2026-06-06): an already-canonical acronym tag
is a no-op, and an all-caps variant does NOT fold into a Title-cased one
(protecting DC/NSFW from becoming Dc/Nsfw is the deliberate trade)."""
await _raw_tag(db, "DC", TagKind.general)
proj = await normalize_existing_tags(db, dry_run=True)
assert proj["total_changes"] == 0 # "DC" is already canonical
# All-caps and Title-case are distinct canonical forms → not merged.
await _raw_tag(db, "Nsfw", TagKind.general)
await _raw_tag(db, "NSFW", TagKind.general)
summary = await normalize_existing_tags(db, dry_run=False)
assert summary["merged"] == 0
assert await _count_named(db, "DC", TagKind.general) == 1
assert await _count_named(db, "NSFW", TagKind.general) == 1
@pytest.mark.asyncio
async def test_same_name_different_fandom_not_merged(db):
f1 = await _raw_tag(db, "Bleach", TagKind.fandom)
+6 -1
View File
@@ -77,7 +77,12 @@ async def test_normalize_tag_name():
from backend.app.services.tag_service import normalize_tag_name
assert normalize_tag_name("hatsune miku") == "Hatsune Miku"
assert normalize_tag_name(" looking at viewer ") == "Looking At Viewer"
assert normalize_tag_name("HATSUNE MIKU") == "Hatsune Miku"
# Acronym-preserving (2026-06-06): only the first letter of each word is
# touched, so already-uppercase tails survive (DC stays DC, not Dc).
assert normalize_tag_name("DC comics") == "DC Comics"
assert normalize_tag_name("dc") == "Dc"
assert normalize_tag_name("NSFW variant") == "NSFW Variant"
assert normalize_tag_name("HATSUNE MIKU") == "HATSUNE MIKU"
@pytest.mark.asyncio