796e92540a
Today the ingest core does `if not media: continue`, so a post with no downloadable media (a pure-text post — which often holds the ONLY copy of an external mega/gdrive/pixeldrain link) never upserts a Post. Now the native ingester emits a post-only sidecar (`_post.json`) for every media-less post, gated through the seen-ledger via a synthetic `post:<id>` key so the body is detail-fetched + recorded ONCE (not re-walked every tick); recovery bypasses the gate. Phase 3 imports these via Importer.upsert_post_record, keyed on external_post_id so it UPDATES the same Post a media import would create — never doubles, never clobbers a populated body with an empty one. - gallery_dl.py: DownloadResult.post_record_paths (default []; gallery-dl path unaffected — all constructions are keyword). - ingest_core.py: media-less branch (optional client/downloader seams via getattr; stub clients in tests skip it as before). - patreon_client.py: post_record_key(post). patreon_downloader.py: write_post_record + _write_sidecar_data refactor (shared serializer). - importer.py: upsert_post_record. download_service.py: phase-3 import loop. - tests: client/downloader/ingester (gate + recovery)/importer (no-double). Slice 0b of milestone #64. Refs FC #830. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
229 lines
8.3 KiB
Python
229 lines
8.3 KiB
Python
"""FC-2d-v: sidecar → Source/Post/ImageProvenance (sync importer)."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
from sqlalchemy import func, select
|
|
|
|
from backend.app.models import (
|
|
Artist,
|
|
ImageProvenance,
|
|
ImageRecord,
|
|
ImportSettings,
|
|
Post,
|
|
Source,
|
|
)
|
|
from backend.app.services.importer import Importer
|
|
from backend.app.services.thumbnailer import Thumbnailer
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@pytest.fixture
|
|
def import_layout(tmp_path):
|
|
import_root = tmp_path / "import"
|
|
images_root = tmp_path / "images"
|
|
import_root.mkdir()
|
|
images_root.mkdir()
|
|
return import_root, images_root
|
|
|
|
|
|
@pytest.fixture
|
|
def importer(db_sync, import_layout):
|
|
import_root, images_root = import_layout
|
|
settings = db_sync.execute(
|
|
select(ImportSettings).where(ImportSettings.id == 1)
|
|
).scalar_one()
|
|
return Importer(
|
|
session=db_sync,
|
|
images_root=images_root,
|
|
import_root=import_root,
|
|
thumbnailer=Thumbnailer(images_root=images_root),
|
|
settings=settings,
|
|
)
|
|
|
|
|
|
def _split(path: Path, orient, size=(256, 256)):
|
|
"""Structured image (half/half) — solid colors phash-collapse, which
|
|
would route a 2nd image to the supersede path (no sidecar applied)."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
w, h = size
|
|
im = Image.new("L", size, 0)
|
|
px = im.load()
|
|
for y in range(h):
|
|
for x in range(w):
|
|
if (x / w if orient == "v" else y / h) >= 0.5:
|
|
px[x, y] = 255
|
|
im.convert("RGB").save(path, "JPEG")
|
|
|
|
|
|
def _sidecar(media: Path, payload: dict):
|
|
media.with_suffix(".json").write_text(json.dumps(payload))
|
|
|
|
|
|
def test_sidecar_creates_provenance(importer, import_layout):
|
|
import_root, _ = import_layout
|
|
m = import_root / "Alice" / "a.jpg"
|
|
_split(m, "v")
|
|
_sidecar(m, {
|
|
"category": "patreon", "id": 555,
|
|
"url": "https://patreon.com/posts/555", "title": "Set 1",
|
|
"content": "<p>hi</p>", "page_count": 2,
|
|
"published_at": "2023-08-01T00:00:00Z",
|
|
})
|
|
r = importer.import_one(m)
|
|
assert r.status == "imported"
|
|
rec = importer.session.get(ImageRecord, r.image_id)
|
|
post = importer.session.execute(select(Post)).scalar_one()
|
|
# Filesystem-imported sidecar posts no longer create a synthetic Source
|
|
# (alembic 0030 / nullable post.source_id refactor). The Post is linked
|
|
# to the artist via Post.artist_id; Post.source_id stays NULL until a
|
|
# real subscription for the (artist, platform) gets added.
|
|
assert post.source_id is None
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Source)
|
|
).scalar_one() == 0
|
|
assert post.external_post_id == "555"
|
|
assert post.post_url == "https://patreon.com/posts/555"
|
|
assert post.post_title == "Set 1"
|
|
assert post.description == "<p>hi</p>"
|
|
assert post.attachment_count == 2
|
|
assert post.post_date is not None
|
|
assert post.raw_metadata["id"] == 555
|
|
prov = importer.session.execute(select(ImageProvenance)).scalar_one()
|
|
assert prov.image_record_id == rec.id and prov.post_id == post.id
|
|
assert rec.primary_post_id == post.id
|
|
# Denormalized gallery sort key (alembic 0035) tracks the primary post's
|
|
# publish date so /scroll orders off ix_image_record_effective_date.
|
|
assert rec.effective_date == post.post_date
|
|
|
|
|
|
def test_reimport_same_post_idempotent(importer, import_layout):
|
|
import_root, _ = import_layout
|
|
# Threshold 0: only an exact phash match collapses. Orthogonal splits
|
|
# still sit within the default Hamming-10 at 64-bit, so without this
|
|
# the 2nd image would be skipped as a near-dup (phash dedup working).
|
|
importer.settings.phash_threshold = 0
|
|
payload = {"category": "patreon", "id": 777, "title": "P"}
|
|
m1 = import_root / "Bob" / "p1.jpg"
|
|
_split(m1, "v")
|
|
_sidecar(m1, payload)
|
|
importer.import_one(m1)
|
|
m2 = import_root / "Bob" / "p2.jpg"
|
|
_split(m2, "h") # distinct phash; threshold 0 → both import
|
|
_sidecar(m2, payload)
|
|
r2 = importer.import_one(m2)
|
|
assert r2.status == "imported"
|
|
# No synthetic Source after alembic 0030; both imports still resolve to
|
|
# a single null-source Post (deduped by uq_post_artist_external_id_null_source).
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Source)
|
|
).scalar_one() == 0
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Post)
|
|
).scalar_one() == 1
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(ImageProvenance)
|
|
).scalar_one() == 2
|
|
|
|
|
|
def test_garbage_sidecar_still_imports(importer, import_layout):
|
|
import_root, _ = import_layout
|
|
m = import_root / "Carol" / "c.jpg"
|
|
_split(m, "v")
|
|
m.with_suffix(".json").write_text("{ not json")
|
|
r = importer.import_one(m)
|
|
assert r.status == "imported"
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Post)
|
|
).scalar_one() == 0
|
|
|
|
|
|
def test_no_sidecar_unchanged(importer, import_layout):
|
|
import_root, _ = import_layout
|
|
m = import_root / "Dave" / "d.jpg"
|
|
_split(m, "v")
|
|
r = importer.import_one(m)
|
|
assert r.status == "imported"
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Post)
|
|
).scalar_one() == 0
|
|
|
|
|
|
def test_no_artist_anywhere_skips_provenance(importer, import_layout):
|
|
import_root, _ = import_layout
|
|
m = import_root / "rootfile.jpg" # no top-level artist folder
|
|
_split(m, "v")
|
|
_sidecar(m, {"category": "x", "id": 1}) # no artist key
|
|
r = importer.import_one(m)
|
|
assert r.status == "imported"
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Source)
|
|
).scalar_one() == 0
|
|
|
|
|
|
def test_sidecar_artist_used_when_no_folder_artist(importer, import_layout):
|
|
import_root, _ = import_layout
|
|
m = import_root / "e.jpg" # root → no folder artist
|
|
_split(m, "v")
|
|
_sidecar(m, {"category": "pixiv", "id": 9, "artist": "Yuki"})
|
|
r = importer.import_one(m)
|
|
assert r.status == "imported"
|
|
a = importer.session.execute(
|
|
select(Artist).where(Artist.slug == "yuki")
|
|
).scalar_one()
|
|
# No synthetic Source after alembic 0030; the artist linkage lives on
|
|
# Post.artist_id (NOT NULL FK).
|
|
post = importer.session.execute(select(Post)).scalar_one()
|
|
assert post.artist_id == a.id
|
|
assert post.source_id is None
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Source)
|
|
).scalar_one() == 0
|
|
|
|
|
|
def test_upsert_post_record_creates_media_less_post(importer, import_layout):
|
|
"""A post-only sidecar (no media) upserts a Post WITH its body — text posts
|
|
are captured even when they have nothing to download."""
|
|
import_root, _ = import_layout
|
|
artist = Artist(name="Alice", slug="alice")
|
|
importer.session.add(artist)
|
|
importer.session.flush()
|
|
sc = import_root / "Alice" / "_post.json"
|
|
sc.parent.mkdir(parents=True, exist_ok=True)
|
|
sc.write_text(json.dumps({
|
|
"category": "patreon", "id": 777,
|
|
"url": "https://patreon.com/posts/777", "title": "Text only",
|
|
"content": "<p>links below: mega</p>",
|
|
"published_at": "2023-08-01T00:00:00Z",
|
|
}))
|
|
assert importer.upsert_post_record(sc, artist=artist) is True
|
|
post = importer.session.execute(select(Post)).scalar_one()
|
|
assert post.external_post_id == "777"
|
|
assert post.post_title == "Text only"
|
|
assert post.description == "<p>links below: mega</p>"
|
|
assert post.post_url == "https://patreon.com/posts/777"
|
|
assert post.post_date is not None
|
|
|
|
|
|
def test_upsert_post_record_idempotent_no_double(importer, import_layout):
|
|
"""Re-running upsert_post_record UPDATES the same Post (keyed on
|
|
external_post_id) — never doubles."""
|
|
import_root, _ = import_layout
|
|
artist = Artist(name="Bob", slug="bob")
|
|
importer.session.add(artist)
|
|
importer.session.flush()
|
|
sc = import_root / "Bob" / "_post.json"
|
|
sc.parent.mkdir(parents=True, exist_ok=True)
|
|
sc.write_text(json.dumps({
|
|
"category": "patreon", "id": 888, "title": "T",
|
|
"content": "<p>body</p>", "url": "https://patreon.com/posts/888",
|
|
}))
|
|
assert importer.upsert_post_record(sc, artist=artist) is True
|
|
assert importer.upsert_post_record(sc, artist=artist) is True
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Post)
|
|
).scalar_one() == 1
|