Compare commits

...

3 Commits

5 changed files with 364 additions and 10 deletions
+13
View File
@@ -237,6 +237,15 @@ class GalleryService:
) )
tags = (await self.session.execute(tag_stmt)).scalars().all() tags = (await self.session.execute(tag_stmt)).scalars().all()
neighbors = await self._neighbors(record) neighbors = await self._neighbors(record)
# Direct artist FK — used by the modal's ProvenancePanel as a
# fallback when ImageProvenance is empty (i.e., filesystem-
# imported images without a post-track provenance row). The
# source of truth for richer post-level data is still
# ImageProvenance/Post; this is just the "we at least know who
# made it" line.
artist = None
if record.artist_id is not None:
artist = await self.session.get(Artist, record.artist_id)
return { return {
"id": record.id, "id": record.id,
"path": record.path, "path": record.path,
@@ -249,6 +258,10 @@ class GalleryService:
"created_at": record.created_at.isoformat(), "created_at": record.created_at.isoformat(),
"thumbnail_url": thumbnail_url(record.sha256, record.mime), "thumbnail_url": thumbnail_url(record.sha256, record.mime),
"image_url": f"/images/{record.path.split('/images/', 1)[-1]}", "image_url": f"/images/{record.path.split('/images/', 1)[-1]}",
"artist": (
{"id": artist.id, "name": artist.name, "slug": artist.slug}
if artist is not None else None
),
"tags": [ "tags": [
{ {
"id": t.id, "id": t.id,
+7 -2
View File
@@ -70,7 +70,7 @@ async def migrate_async(
""" """
if data.get("source_app") != "imagerepo": if data.get("source_app") != "imagerepo":
raise ValueError("export source_app must be 'imagerepo'") raise ValueError("export source_app must be 'imagerepo'")
if data.get("schema_version") != 1: if data.get("schema_version") not in (1, 2):
raise ValueError(f"unsupported schema_version: {data.get('schema_version')}") raise ValueError(f"unsupported schema_version: {data.get('schema_version')}")
counts = _zero_counts() counts = _zero_counts()
@@ -126,15 +126,20 @@ async def migrate_async(
await db.commit() await db.commit()
# Phase 2: write the per-image manifest for tag_apply.py to consume later. # Phase 2: write the per-image manifest for tag_apply.py to consume later.
# schema_version 2 (added 2026-05-24) carries `image_posts` for
# Post + Source + ImageProvenance restore; schema 1 manifests
# without it stay valid (tag_apply treats the missing field as []).
manifest = { manifest = {
"schema_version": 1, "schema_version": data.get("schema_version", 1),
"image_artist_assignments": data.get("image_artist_assignments", []), "image_artist_assignments": data.get("image_artist_assignments", []),
"image_tag_associations": data.get("image_tag_associations", []), "image_tag_associations": data.get("image_tag_associations", []),
"series_pages": data.get("series_pages", []), "series_pages": data.get("series_pages", []),
"image_posts": data.get("image_posts", []),
} }
counts["rows_processed"] += len(manifest["image_artist_assignments"]) counts["rows_processed"] += len(manifest["image_artist_assignments"])
counts["rows_processed"] += len(manifest["image_tag_associations"]) counts["rows_processed"] += len(manifest["image_tag_associations"])
counts["rows_processed"] += len(manifest["series_pages"]) counts["rows_processed"] += len(manifest["series_pages"])
counts["rows_processed"] += len(manifest["image_posts"])
if not dry_run: if not dry_run:
manifest_path(images_root).write_text(json.dumps(manifest, indent=2)) manifest_path(images_root).write_text(json.dumps(manifest, indent=2))
+166 -1
View File
@@ -7,6 +7,7 @@ FC's filesystem scan over the mounted IR images dir).
- image_artist_assignments → ImageRecord.artist_id (find_or_create Artist by slug). - image_artist_assignments → ImageRecord.artist_id (find_or_create Artist by slug).
- image_tag_associations → image_tag insert (idempotent). - image_tag_associations → image_tag insert (idempotent).
- series_pages → series_page insert (idempotent on image_id unique). - series_pages → series_page insert (idempotent on image_id unique).
- image_posts (schema v2) → Source + Post + ImageProvenance restore.
Unmatched sha256s are logged into the result's `unmatched` list so the Unmatched sha256s are logged into the result's `unmatched` list so the
Celery task can drop them into MigrationRun.metadata for the operator Celery task can drop them into MigrationRun.metadata for the operator
@@ -15,15 +16,118 @@ to inspect.
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime
from pathlib import Path from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ...models import Artist, ImageRecord, SeriesPage, Tag, TagKind, image_tag from ...models import (
Artist,
ImageProvenance,
ImageRecord,
Post,
SeriesPage,
Source,
Tag,
TagKind,
image_tag,
)
from ...utils.slug import slugify from ...utils.slug import slugify
from .ir_ingest import manifest_path 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.
_PLATFORM_PROFILE_URL = {
"patreon": "https://www.patreon.com/{slug}",
"subscribestar": "https://www.subscribestar.com/{slug}",
"hentaifoundry": "https://www.hentai-foundry.com/user/{slug}",
}
def _profile_url(platform: str, artist_slug: str) -> str | None:
fmt = _PLATFORM_PROFILE_URL.get(platform)
return fmt.format(slug=artist_slug) if fmt else None
async def _find_or_create_source(
db: AsyncSession, *, artist_id: int, platform: str, url: str, dry_run: bool,
) -> int | None:
existing = (await db.execute(
select(Source.id).where(
Source.artist_id == artist_id,
Source.platform == platform,
Source.url == url,
)
)).scalar_one_or_none()
if existing is not None:
return existing
if dry_run:
return None
s = Source(artist_id=artist_id, platform=platform, url=url, enabled=False)
db.add(s)
await db.flush()
return s.id
async def _find_or_create_post(
db: AsyncSession, *,
source_id: int, external_post_id: str,
title: str | None, description: str | None, post_url: str | None,
post_date_iso: str | None, attachment_count: int, dry_run: bool,
) -> int | None:
existing = (await db.execute(
select(Post.id).where(
Post.source_id == source_id,
Post.external_post_id == external_post_id,
)
)).scalar_one_or_none()
if existing is not None:
return existing
if dry_run:
return None
post_date = None
if post_date_iso:
post_date = datetime.fromisoformat(post_date_iso)
p = Post(
source_id=source_id,
external_post_id=external_post_id,
post_title=title,
description=description,
post_url=post_url,
post_date=post_date,
attachment_count=attachment_count,
raw_metadata={"migrated_from": "imagerepo"},
)
db.add(p)
await db.flush()
return p.id
async def _ensure_provenance(
db: AsyncSession, *,
image_id: int, post_id: int, source_id: int, dry_run: bool,
) -> bool:
"""Returns True if a new ImageProvenance row was inserted."""
existing = (await db.execute(
select(ImageProvenance.id).where(
ImageProvenance.image_record_id == image_id,
ImageProvenance.post_id == post_id,
ImageProvenance.source_id == source_id,
)
)).scalar_one_or_none()
if existing is not None:
return False
if dry_run:
return True
db.add(ImageProvenance(
image_record_id=image_id, post_id=post_id, source_id=source_id,
))
await db.flush()
return True
def _zero_counts() -> dict: def _zero_counts() -> dict:
return { return {
@@ -167,6 +271,67 @@ async def apply_async(
)) ))
counts["rows_inserted"] += 1 counts["rows_inserted"] += 1
# 4. Image posts (schema v2) → Source + Post + ImageProvenance.
# Restores IR PostMetadata as FC's downloader-track provenance,
# so the modal's ProvenancePanel surfaces title/description/
# source URL/publish date the same way it does for live
# gallery-dl downloads.
for entry in manifest.get("image_posts", []):
counts["rows_processed"] += 1
platform = entry.get("platform")
artist_name = entry.get("artist")
if not platform or not artist_name:
counts["rows_skipped"] += 1
continue
aid = await _ensure_artist_id(db, artist_name, dry_run)
if aid is None:
counts["rows_skipped"] += 1
continue
url = _profile_url(platform, slugify(artist_name))
if url is None:
counts["rows_skipped"] += 1
continue
source_id = await _find_or_create_source(
db, artist_id=aid, platform=platform, url=url, dry_run=dry_run,
)
if source_id is None:
counts["rows_skipped"] += 1
continue
post_id = await _find_or_create_post(
db, source_id=source_id,
external_post_id=entry.get("post_id") or "",
title=entry.get("title"),
description=entry.get("description"),
post_url=entry.get("source_url"),
post_date_iso=entry.get("published_at"),
attachment_count=entry.get("attachment_count") or 0,
dry_run=dry_run,
)
if post_id is None:
counts["rows_skipped"] += 1
continue
for sha in entry.get("image_sha256s", []):
img_id = await _sha_to_image_id(db, sha)
if img_id is None:
unmatched.append({
"kind": "post", "sha256": sha,
"post_id": entry.get("post_id"),
})
continue
inserted = await _ensure_provenance(
db, image_id=img_id, post_id=post_id,
source_id=source_id, dry_run=dry_run,
)
if inserted:
counts["rows_inserted"] += 1
else:
counts["rows_skipped"] += 1
if not dry_run: if not dry_run:
await db.commit() await db.commit()
return {"counts": counts, "unmatched": unmatched} return {"counts": counts, "unmatched": unmatched}
@@ -43,6 +43,21 @@
class="fc-prov__desc" v-html="e.post.description_html" class="fc-prov__desc" v-html="e.post.description_html"
/> />
</article> </article>
<!-- Fallback: image has artist_id set (folder-derived from
filesystem import or set by tag_apply) but no ImageProvenance
row exists. Show a minimal "by <artist>" line so the modal
isn't blank for IR-imported images that aren't in any post. -->
<article
v-if="showArtistFallback"
class="fc-prov__card"
>
<div class="fc-prov__meta">
<RouterLink :to="`/artist/${fallbackArtist.slug}`">
by {{ fallbackArtist.name }}
</RouterLink>
</div>
</article>
</template> </template>
<div v-if="attachments.length" class="fc-prov__attach"> <div v-if="attachments.length" class="fc-prov__attach">
@@ -79,13 +94,27 @@ const state = computed(() =>
modal.currentImageId == null ? null : prov.imageProv(modal.currentImageId) modal.currentImageId == null ? null : prov.imageProv(modal.currentImageId)
) )
// Show the panel while loading or on error, or when there is >=1 entry. const fallbackArtist = computed(() => modal.current?.artist || null)
// Hide entirely when the image has zero provenance.
const showArtistFallback = computed(() => {
const st = state.value
// Only show the fallback when provenance is genuinely empty (no
// entries, not loading, no error) AND the image has a direct
// artist_id we can surface.
if (!st || st.loading || st.error) return false
if (Array.isArray(st.entries) && st.entries.length > 0) return false
return fallbackArtist.value != null
})
// Show the panel while loading or on error, when there is >=1 entry,
// or when the artist fallback applies (image_record.artist_id set but
// no ImageProvenance row, e.g. filesystem-imported IR images).
const show = computed(() => { const show = computed(() => {
const st = state.value const st = state.value
if (!st) return false if (!st) return false
if (st.loading || st.error) return true if (st.loading || st.error) return true
return Array.isArray(st.entries) && st.entries.length > 0 if (Array.isArray(st.entries) && st.entries.length > 0) return true
return showArtistFallback.value
}) })
const attachments = computed(() => state.value?.attachments || []) const attachments = computed(() => state.value?.attachments || [])
+146 -4
View File
@@ -2,9 +2,19 @@
import json import json
import pytest import pytest
from sqlalchemy import select from sqlalchemy import func, select
from backend.app.models import Artist, ImageRecord, SeriesPage, Tag, TagKind, image_tag from backend.app.models import (
Artist,
ImageProvenance,
ImageRecord,
Post,
SeriesPage,
Source,
Tag,
TagKind,
image_tag,
)
from backend.app.services.migrators import tag_apply from backend.app.services.migrators import tag_apply
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@@ -21,14 +31,19 @@ async def _seed_image(db, sha: str, *, suffix="x"):
return img return img
def _write_manifest(tmp_path, *, artist_assignments=None, tag_associations=None, series_pages=None): def _write_manifest(
tmp_path, *,
artist_assignments=None, tag_associations=None,
series_pages=None, image_posts=None, schema_version=2,
):
d = tmp_path / "_migration_state" d = tmp_path / "_migration_state"
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
(d / "ir_tag_manifest.json").write_text(json.dumps({ (d / "ir_tag_manifest.json").write_text(json.dumps({
"schema_version": 1, "schema_version": schema_version,
"image_artist_assignments": artist_assignments or [], "image_artist_assignments": artist_assignments or [],
"image_tag_associations": tag_associations or [], "image_tag_associations": tag_associations or [],
"series_pages": series_pages or [], "series_pages": series_pages or [],
"image_posts": image_posts or [],
})) }))
@@ -154,3 +169,130 @@ async def test_dry_run_makes_no_changes(db, tmp_path):
select(image_tag.c.tag_id).where(image_tag.c.image_record_id == img_id) select(image_tag.c.tag_id).where(image_tag.c.image_record_id == img_id)
)).all() )).all()
assert len(rows) == 0 assert len(rows) == 0
# --- Phase 4: image_posts (schema v2) -----------------------------------
_POST_ENTRY = {
"platform": "patreon",
"post_id": "10001",
"artist": "Maewix",
"title": "Test Post Title",
"description": "<p>HTML description</p>",
"source_url": "https://www.patreon.com/posts/test-10001",
"attachment_count": 1,
"published_at": "2026-01-15T12:00:00+00:00",
}
@pytest.mark.asyncio
async def test_image_posts_creates_source_post_provenance(db, tmp_path):
sha = "d" * 64
await _seed_image(db, sha, suffix="d")
await db.commit()
_write_manifest(tmp_path, image_posts=[
{**_POST_ENTRY, "image_sha256s": [sha]},
])
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
assert result["counts"]["rows_inserted"] >= 1
# Async Core-DML assertions — column selects, not ORM attribute access.
artist_count = (await db.execute(
select(func.count(Artist.id)).where(Artist.slug == "maewix")
)).scalar_one()
assert artist_count == 1
source_count = (await db.execute(
select(func.count(Source.id)).where(
Source.platform == "patreon",
Source.url == "https://www.patreon.com/maewix",
)
)).scalar_one()
assert source_count == 1
post_count = (await db.execute(
select(func.count(Post.id)).where(Post.external_post_id == "10001")
)).scalar_one()
assert post_count == 1
img_id = (await db.execute(
select(ImageRecord.id).where(ImageRecord.sha256 == sha)
)).scalar_one()
prov_count = (await db.execute(
select(func.count(ImageProvenance.id))
.where(ImageProvenance.image_record_id == img_id)
)).scalar_one()
assert prov_count == 1
@pytest.mark.asyncio
async def test_image_posts_idempotent_on_rerun(db, tmp_path):
sha = "e" * 64
await _seed_image(db, sha, suffix="e")
await db.commit()
_write_manifest(tmp_path, image_posts=[
{**_POST_ENTRY, "image_sha256s": [sha]},
])
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
result2 = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
# Re-run: nothing should be newly inserted; all skipped.
assert result2["counts"]["rows_inserted"] == 0
assert result2["counts"]["rows_skipped"] >= 1
prov_count = (await db.execute(
select(func.count(ImageProvenance.id))
)).scalar_one()
assert prov_count == 1
@pytest.mark.asyncio
async def test_image_posts_unknown_sha_unmatched(db, tmp_path):
# No image seeded; sha doesn't exist.
_write_manifest(tmp_path, image_posts=[
{**_POST_ENTRY, "image_sha256s": ["f" * 64]},
])
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
unmatched_kinds = [u["kind"] for u in result["unmatched"]]
assert "post" in unmatched_kinds
@pytest.mark.asyncio
async def test_image_posts_unknown_platform_skipped(db, tmp_path):
sha = "1" * 64
await _seed_image(db, sha, suffix="1")
await db.commit()
_write_manifest(tmp_path, image_posts=[
{**_POST_ENTRY, "platform": "tumblr", "image_sha256s": [sha]},
])
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
assert result["counts"]["rows_skipped"] >= 1
# No Post / Source / ImageProvenance should have been created.
src_count = (await db.execute(
select(func.count(Source.id)).where(Source.platform == "tumblr")
)).scalar_one()
assert src_count == 0
@pytest.mark.asyncio
async def test_image_posts_dry_run_makes_no_writes(db, tmp_path):
sha = "2" * 64
await _seed_image(db, sha, suffix="2")
await db.commit()
_write_manifest(tmp_path, image_posts=[
{**_POST_ENTRY, "image_sha256s": [sha]},
])
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=True)
prov_count = (await db.execute(
select(func.count(ImageProvenance.id))
)).scalar_one()
assert prov_count == 0