Compare commits

..
7 Commits
Author SHA1 Message Date
bvandeusen ef8f4f7193 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
2026-06-06 19:59:32 -04:00
bvandeusenandClaude Opus 4.8 19eb4e9388 feat(import): surface WHY an archive was captured without extracting images
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
The recurring "post shows a zip but no images" report had no diagnostic: when
_import_archive captured an archive as a bare PostAttachment — because the
bomb-guard probe rejected it, or extraction yielded zero members (corrupt /
unsupported / missing extractor backend), or it held only non-media files — it
returned status="attached" silently.

Now those paths set ImportResult.error with the specific reason and log a
warning, and download_service records each as {file, reason} under the event's
metadata.unextracted_archives (None when every archive extracted cleanly). So
the next run names exactly which archives failed and why, instead of leaving the
operator to guess. No behaviour change to the happy path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:01:22 -04:00
bvandeusenandClaude Opus 4.8 a559fabdd5 feat(patreon): scrape creator-page HTML as a campaign-id resolution fallback
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 27s
CI / integration (push) Successful in 3m1s
CI / backend-lint-and-test (push) Successful in 25s
Patreon's /api/campaigns?filter[vanity]= lookup returns empty data for creators
that plainly exist (operator-flagged 2026-06-06 — Atole etc. erroring at the
resolve step). gallery-dl never used that endpoint; it pulls the campaign id out
of the creator page's bootstrap JSON. Add the same as a fallback: when the API
misses, GET the creator page (bare + /c/ vanity paths) and scrape the first
campaign id from any known embedding ("id":"…","type":"campaign" /
"campaign":{"data":{"id" / /api/campaigns/<id> / "campaign_id"). API is still
tried first (cheap, structured); the page scrape only runs on a miss.

Tests: API-empty → page-scrape fallback resolves; _scrape_campaign_id pattern
coverage. Existing API-path tests unchanged (happy paths short-circuit before
the fallback; failure paths hit the guarded scrape and still return None).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:57:39 -04:00
bvandeusenandClaude Opus 4.8 711fd2bb75 style: drop aliased cross-module import (I001) — use a local _CAMPAIGNS_API constant
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 27s
CI / integration (push) Successful in 3m0s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:46:09 -04:00
bvandeusenandClaude Opus 4.8 3556a54260 feat(patreon): surface source URL + vanity in campaign-id resolution errors
CI / lint (push) Failing after 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m2s
Naming/lookup failures now report the source_url, the extracted vanity, and the
exact campaigns-API lookup URL attempted, so a "could not resolve campaign id"
error is diagnosable (wrong vanity? cookie/auth? creator renamed?) instead of
opaque. Applied to all three resolution surfaces: the native download event,
the dry-run preview, and the credential-verify probe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:39:54 -04:00
bvandeusenandClaude Opus 4.8 df2310bc70 fix(tags): preserve acronym casing in tag normalization (DC, NSFW)
normalize_tag_name now only capitalizes the first letter of each word and
leaves the rest of the word untouched (was lowercasing the tail, which turned
DC→Dc / NSFW→Nsfw). This matches ml/tag_name._title_word, so a Camie-suggested
tag keeps the exact casing the suggestion UI showed when it round-trips through
POST /api/tags on Accept — addressing "auto-suggested tags must obey
capitalization" and "don't mangle acronyms" in one rule.

Trade-off (operator-chosen): all-caps input no longer folds to Title Case, so
case-variant merging in #714 still folds the dominant lowercase-vs-Title case
but leaves all-caps stylizations distinct (protecting acronyms wins). Tests
updated + a new test documenting acronym preservation / non-folding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:39:54 -04:00
bvandeusenandClaude Opus 4.8 9374f63953 fix(posts): post-card thumbnail strip spans the full hero width
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 3m1s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 25s
The hero collage's thumbnail rail hard-capped at 3 fixed-80px cells, left-
aligned, so it never reached the edge of the (50%-width) hero. Make the rail a
CSS grid of equal columns (1fr) at a fixed height that stretches to the hero's
full width: show up to 5 thumbnails, and when a post has more images than fit,
the last cell becomes the "+N" overflow tile (count unchanged). Column count is
driven by --fc-rail-cols so the strip always reaches the hero edge regardless
of image count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:04:23 -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 .gallery_dl import DownloadResult, ErrorType
from .patreon_ingester import PatreonIngester 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 # Platforms whose download + verify go through the native ingester rather than
# gallery-dl. gallery-dl still serves every other platform (subscribestar, # gallery-dl. gallery-dl still serves every other platform (subscribestar,
# hentaifoundry, discord, pixiv, deviantart) unchanged. # hentaifoundry, discord, pixiv, deviantart) unchanged.
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon"}) 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: def uses_native_ingester(platform: str) -> bool:
"""True when `platform` is served by the native ingester (not gallery-dl). """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 ctx["url"], ctx["cookies_path"], overrides
) )
if not campaign_id: if not campaign_id:
url = ctx["url"]
vanity = extract_vanity(url)
return ( return (
DownloadResult( DownloadResult(
success=False, success=False,
url=ctx["url"], url=url,
artist_slug=ctx["artist_slug"], artist_slug=ctx["artist_slug"],
platform="patreon", platform="patreon",
error_type=ErrorType.NOT_FOUND, error_type=ErrorType.NOT_FOUND,
error_message=( 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?)" "(vanity lookup failed — cookies expired or creator moved?)"
), ),
), ),
@@ -172,9 +180,11 @@ async def preview_source(
url, cookies_path, config_overrides or {} url, cookies_path, config_overrides or {}
) )
if not campaign_id: if not campaign_id:
vanity = extract_vanity(url)
return { return {
"error": ( "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?)." "(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"]) source_row = self.sync_session.get(Source, ctx["source_id"])
import_summary = {"attached": 0, "skipped": 0, "errors": 0} 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 bytes_downloaded = 0
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -322,6 +327,17 @@ class DownloadService:
# mirror duplicate_hash cleanup so we don't keep two copies. # mirror duplicate_hash cleanup so we don't keep two copies.
# Operator-flagged 2026-06-02 (Lustria OST zip). # Operator-flagged 2026-06-02 (Lustria OST zip).
import_summary["attached"] += 1 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: try:
bytes_downloaded += path.stat().st_size # noqa: ASYNC240 bytes_downloaded += path.stat().st_size # noqa: ASYNC240
except OSError: except OSError:
@@ -403,6 +419,10 @@ class DownloadService:
"duration_seconds": dl_result.duration_seconds, "duration_seconds": dl_result.duration_seconds,
"quarantined_paths": dl_result.quarantined_paths or None, "quarantined_paths": dl_result.quarantined_paths or None,
"import_summary": import_summary, "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( await self._update_source_health(
source_id=ctx["source_id"], status=status, error_message=ev.error, source_id=ctx["source_id"], status=status, error_message=ev.error,
+18 -2
View File
@@ -430,13 +430,17 @@ class Importer:
self._capture_attachment( self._capture_attachment(
source, post=post, artist=artist_use, resolved=True, 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) artist_use = artist if artist is not None else self._resolve_artist(source)
post = self._post_for_sidecar(source, artist_use) post = self._post_for_sidecar(source, artist_use)
member_ids: list[int] = [] member_ids: list[int] = []
member_total = 0
with extract_archive(source) as members: with extract_archive(source) as members:
for _name, member_path in members: for _name, member_path in members:
member_total += 1
if not is_supported(member_path): if not is_supported(member_path):
continue # non-media preserved via the stored archive continue # non-media preserved via the stored archive
res = self._import_media( res = self._import_media(
@@ -453,7 +457,19 @@ class Importer:
status="imported", image_id=member_ids[0], status="imported", image_id=member_ids[0],
member_image_ids=member_ids, 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( def _import_media(
self, source: Path, attribution_path: Path, self, source: Path, attribution_path: Path,
+5 -3
View File
@@ -45,7 +45,7 @@ from .patreon_client import (
PatreonDriftError, PatreonDriftError,
) )
from .patreon_downloader import PatreonDownloader 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__ = [ __all__ = [
"DEAD_LETTER_THRESHOLD", "DEAD_LETTER_THRESHOLD",
@@ -186,9 +186,11 @@ async def verify_patreon_credential(
""" """
campaign_id, _ = await resolve_campaign_id_for_source(url, cookies_path, overrides) campaign_id, _ = await resolve_campaign_id_for_source(url, cookies_path, overrides)
if not campaign_id: if not campaign_id:
vanity = extract_vanity(url)
return None, ( return None, (
"Couldn't resolve the Patreon campaign id from the source URL — " f"Couldn't resolve the Patreon campaign id — can't verify. "
"can't verify (cookies expired, or the creator moved/renamed?)." f"source_url={url!r}; vanity={vanity!r} "
"(cookies expired, or the creator moved/renamed?)."
) )
client = PatreonClient(cookies_path) client = PatreonClient(cookies_path)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
+66
View File
@@ -41,6 +41,19 @@ _USER_AGENT = (
) )
_TIMEOUT_SECONDS = 10.0 _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: def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJar | None:
if not cookies_path or not os.path.isfile(cookies_path): 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: 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) jar = _load_cookie_jar(cookies_path)
headers = { headers = {
"User-Agent": _USER_AGENT, "User-Agent": _USER_AGENT,
@@ -102,6 +124,50 @@ def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
return campaign_id 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( async def resolve_campaign_id(
vanity: str, vanity: str,
cookies_path: str | None, cookies_path: str | None,
+9 -5
View File
@@ -17,15 +17,19 @@ log = logging.getLogger(__name__)
def normalize_tag_name(name: str) -> str: 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: Per-word capitalize (NOT str.title(), which mangles apostrophes:
don't → Don'T). Lowercasing the word tail also folds ALL-CAPS input don't → Don'T). The word tail is left untouched so acronyms survive
(HATSUNE MIKU → Hatsune Miku); acronym casing is sacrificed, an accepted (DC stays DC, NSFW stays NSFW) — operator-revised 2026-06-06: protecting
Title-Case trade-off (operator-chosen 2026-06-06). 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( 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" /> <img :src="hero.thumbnail_url" alt="hero thumbnail" loading="lazy" />
</button> </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 <button
v-for="t in rail" :key="t.image_id" type="button" v-for="t in rail" :key="t.image_id" type="button"
class="fc-post-card__rail-cell" 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 plainTitle = computed(() => toPlainText(props.post.post_title))
const hero = computed(() => images.value[0]) 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 visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
const moreCount = computed(() => { const moreCount = computed(() => {
const more = props.post.thumbnails_more || 0 if (!hasOverflow.value) return 0
const extraShown = Math.max(0, images.value.length - visibleCount.value) return serverMore.value + Math.max(0, images.value.length - visibleCount.value)
return more + extraShown
}) })
// 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 sortDateIso = computed(() => props.post.post_date || props.post.downloaded_at)
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString()) const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
@@ -279,11 +298,16 @@ function formatBytes (n) {
.fc-post-card__hero:hover img, .fc-post-card__hero:hover img,
.fc-post-card__rail-cell:hover img { transform: scale(1.03); filter: brightness(1.08); } .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 { .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 { .fc-post-card__rail-cell {
width: 80px; height: 80px; width: 100%; height: 84px;
overflow: hidden; border-radius: 4px; overflow: hidden; border-radius: 4px;
} }
.fc-post-card__rail-cell img { .fc-post-card__rail-cell img {
@@ -291,7 +315,7 @@ function formatBytes (n) {
object-fit: cover; display: block; object-fit: cover; display: block;
} }
.fc-post-card__rail-more { .fc-post-card__rail-more {
width: 80px; height: 80px; width: 100%; height: 84px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
border: 1px dashed rgb(var(--v-theme-on-surface-variant)); border: 1px dashed rgb(var(--v-theme-on-surface-variant));
border-radius: 4px; border-radius: 4px;
+24
View File
@@ -40,6 +40,30 @@ describe('PostCard', () => {
expect(full.text()).not.toContain('Show more') 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 () => { it('clicking an image opens the post-scoped modal (never expands the card)', async () => {
const pinia = freshPinia() const pinia = freshPinia()
const modal = useModalStore() const modal = useModalStore()
+3 -2
View File
@@ -74,13 +74,14 @@ async def test_create_tag_with_kind_prefix(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_explicit_kind_overrides_prefix_parsing(client): async def test_explicit_kind_overrides_prefix_parsing(client):
"""If caller passes explicit kind, don't re-parse the name — """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( resp = await client.post(
"/api/tags", json={"name": "character:Saber", "kind": "general"} "/api/tags", json={"name": "character:Saber", "kind": "general"}
) )
assert resp.status_code == 201 assert resp.status_code == 201
body = await resp.get_json() body = await resp.get_json()
assert body["name"] == "Character:saber" assert body["name"] == "Character:Saber"
assert body["kind"] == "general" 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 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) ---------- # -- 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 @pytest.mark.asyncio
async def test_live_merges_case_variants_and_repoints_images(db): 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) 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) i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await _link(db, i1, a.id) await _link(db, i1, a.id)
await _link(db, i2, 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 @pytest.mark.asyncio
async def test_idempotent_second_run_is_noop(db): 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) await _raw_tag(db, "Kafka", TagKind.character)
first = await normalize_existing_tags(db, dry_run=False) first = await normalize_existing_tags(db, dry_run=False)
assert first["groups_processed"] == 1 assert first["groups_processed"] == 1
@@ -138,6 +139,24 @@ async def test_idempotent_second_run_is_noop(db):
assert again["renamed"] == 0 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 @pytest.mark.asyncio
async def test_same_name_different_fandom_not_merged(db): async def test_same_name_different_fandom_not_merged(db):
f1 = await _raw_tag(db, "Bleach", TagKind.fandom) 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 from backend.app.services.tag_service import normalize_tag_name
assert normalize_tag_name("hatsune miku") == "Hatsune Miku" assert normalize_tag_name("hatsune miku") == "Hatsune Miku"
assert normalize_tag_name(" looking at viewer ") == "Looking At Viewer" 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 @pytest.mark.asyncio