Tag-casing acronym fix, Patreon resolver hardening, archive diagnostics, post-card strip #74
@@ -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?)."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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) ----------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user