Compare commits
2
Commits
9374f63953
...
3556a54260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3556a54260 | ||
|
|
df2310bc70 |
@@ -25,7 +25,11 @@ 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 (
|
||||||
|
_CAMPAIGNS_URL as _CAMPAIGNS_API,
|
||||||
|
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,
|
||||||
@@ -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?)."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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