Tag-casing acronym fix, Patreon resolver hardening, archive diagnostics, post-card strip #74

Merged
bvandeusen merged 6 commits from dev into main 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 .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