diff --git a/backend/app/services/migrators/tag_apply.py b/backend/app/services/migrators/tag_apply.py index 5db92ff..cfcb585 100644 --- a/backend/app/services/migrators/tag_apply.py +++ b/backend/app/services/migrators/tag_apply.py @@ -37,13 +37,25 @@ from ...utils.slug import slugify from .ir_ingest import manifest_path # Per-platform artist-profile URL — used as Source.url when restoring -# IR PostMetadata into FC. Keep this table in sync with -# backend/app/services/extension_service.py:_PLATFORM_PATTERNS and -# extension/lib/platforms.js. +# IR PostMetadata into FC. Must cover every platform that +# backend/app/services/extension_service.py:_PLATFORM_PATTERNS +# recognizes; an entry missing here silently drops ALL PostMetadata for +# that platform during phase 4 (operator hit this 2026-05-25: +# DeviantArt + Pixiv posts in the IR migration produced empty +# ImageProvenance because they fell through this table). +# +# Pixiv caveat: the real profile URL takes a numeric user_id +# (https://www.pixiv.net/users/12345), but IR's PostMetadata.artist +# stores the display name not the id. We use the slugified name here +# so we preserve the artist→post→image linkage; the resulting Source.url +# won't resolve in a browser and the operator may want to manually fix +# it via Settings → Subscriptions once the migration lands. _PLATFORM_PROFILE_URL = { "patreon": "https://www.patreon.com/{slug}", "subscribestar": "https://www.subscribestar.com/{slug}", "hentaifoundry": "https://www.hentai-foundry.com/user/{slug}", + "deviantart": "https://www.deviantart.com/{slug}", + "pixiv": "https://www.pixiv.net/users/{slug}", } diff --git a/tests/test_tag_apply.py b/tests/test_tag_apply.py index cee72c0..cf1a5c2 100644 --- a/tests/test_tag_apply.py +++ b/tests/test_tag_apply.py @@ -281,6 +281,42 @@ async def test_image_posts_unknown_platform_skipped(db, tmp_path): assert src_count == 0 +@pytest.mark.parametrize("platform,expected_url", [ + ("deviantart", "https://www.deviantart.com/maewix"), + ("pixiv", "https://www.pixiv.net/users/maewix"), +]) +@pytest.mark.asyncio +async def test_image_posts_extended_platforms_create_source( + db, tmp_path, platform, expected_url, +): + """Regression for 2026-05-25 operator-reported bug: phase 4's + _PLATFORM_PROFILE_URL had only patreon/subscribestar/hentaifoundry, + silently dropping deviantart + pixiv PostMetadata from the IR migration.""" + sha = f"{platform[0]}" * 64 + await _seed_image(db, sha, suffix=f"_{platform}") + await db.commit() + + _write_manifest(tmp_path, image_posts=[ + {**_POST_ENTRY, "platform": platform, "image_sha256s": [sha]}, + ]) + + result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False) + assert result["counts"]["rows_inserted"] >= 1 + + src_url = (await db.execute( + select(Source.url).where(Source.platform == platform) + )).scalar_one() + assert src_url == expected_url + + # ImageProvenance row was created. + prov_count = (await db.execute( + select(func.count(ImageProvenance.id)) + .join(Source, Source.id == ImageProvenance.source_id) + .where(Source.platform == platform) + )).scalar_one() + assert prov_count == 1 + + @pytest.mark.asyncio async def test_image_posts_dry_run_makes_no_writes(db, tmp_path): sha = "2" * 64