289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""sidecar-audit followup: correct external_post_id + post_url across all platforms
|
|
|
|
Revision ID: 0025
|
|
Revises: 0024
|
|
Create Date: 2026-05-27
|
|
|
|
Closes the operator-flagged 2026-05-27 sidecar audit findings. Three
|
|
data-correctness bugs across non-Patreon platforms had been silently
|
|
corrupting Posts since FC-3 shipped; the parser fix (sidecar.py, same
|
|
commit) addresses new imports. This migration cleans up existing rows.
|
|
|
|
Per-platform actions:
|
|
|
|
subscribestar — gallery-dl wrote the per-attachment id in `id` and
|
|
the actual post id in `post_id`. FC's parser picked `id`, so every
|
|
multi-image SubscribeStar post was fragmented into N Post rows.
|
|
1. For each SubscribeStar Post, read its sidecar (via the related
|
|
ImageRecord's on-disk path), pull `post_id`, overwrite
|
|
external_post_id and post_url.
|
|
2. Merge groups of Posts under one source that now share an
|
|
external_post_id (fragments of the same actual post). Same
|
|
ImageProvenance pre-delete + repoint dance as alembic 0022.
|
|
|
|
hentaifoundry — sidecars have NO `url` field; `src` is the image
|
|
URL. FC's parser stored post_url=NULL. Read each HF Post's sidecar
|
|
for `user` + `index`, derive the canonical /pictures/user/<u>/<i>
|
|
permalink. external_post_id (= `index`) was already correct.
|
|
|
|
discord — gallery-dl wrote the CDN attachment URL in `url`. FC's
|
|
parser stored that as post_url. Read each Discord Post's sidecar
|
|
for the server/channel/message triple, derive the proper
|
|
discord.com/channels/.../<message> permalink. external_post_id (=
|
|
`message_id`) was already correct.
|
|
|
|
pixiv — pure-SQL backfill: replace any `i.pximg.net`-style URL on
|
|
Post.post_url with the derived `/artworks/<id>` permalink. Pixiv
|
|
external_post_id (= `id`) was already correct; no sidecar IO
|
|
needed.
|
|
|
|
Idempotent: re-running on already-corrected data is a no-op (skips
|
|
rows whose derived value matches what's already stored).
|
|
|
|
Posts whose related ImageRecord paths don't resolve on disk (orphaned
|
|
filesystem state) are skipped with a count in the migration output —
|
|
those will be picked up by a future deep-scan.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Sequence, Union
|
|
|
|
from alembic import op
|
|
from sqlalchemy import text
|
|
|
|
revision: str = "0025"
|
|
down_revision: Union[str, None] = "0024"
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
# Mirror of sidecar._NUMBERING_PREFIX. Kept inline so the migration is
|
|
# self-contained (the operator's banked rule:
|
|
# reference_postgres_enum_swap_drop_checks.md says migrations shouldn't
|
|
# import from runtime app code).
|
|
_NUMBERING_PREFIX = re.compile(r"^\d+_(.+)$")
|
|
|
|
|
|
def _find_sidecar(media_path: Path) -> Path | None:
|
|
"""gallery-dl writes the sidecar under the unprefixed stem
|
|
(`HOLLOW-ICHIGO.json`) while the media file gets a NN_ ordering
|
|
prefix (`01_HOLLOW-ICHIGO.png`). Try in order:
|
|
1. <stem>.json next to the media
|
|
2. <media>.json next to the media (full-name variant)
|
|
3. strip the NN_ prefix from the stem, then <stripped>.json
|
|
"""
|
|
if not media_path:
|
|
return None
|
|
cand = media_path.with_suffix(".json")
|
|
if cand.is_file():
|
|
return cand
|
|
cand = media_path.parent / f"{media_path.name}.json"
|
|
if cand.is_file():
|
|
return cand
|
|
m = _NUMBERING_PREFIX.match(media_path.stem)
|
|
if m:
|
|
cand = media_path.parent / f"{m.group(1)}.json"
|
|
if cand.is_file():
|
|
return cand
|
|
return None
|
|
|
|
|
|
def _str_id(v) -> str | None:
|
|
"""str() a JSON scalar id; reject bool (JSON booleans are ints in
|
|
Python's eyes but they aren't valid sidecar ids)."""
|
|
if isinstance(v, bool):
|
|
return None
|
|
if isinstance(v, (str, int)) and str(v).strip():
|
|
return str(v).strip()
|
|
return None
|
|
|
|
|
|
def _str_field(v) -> str | None:
|
|
if isinstance(v, str) and v.strip():
|
|
return v.strip()
|
|
return None
|
|
|
|
|
|
def upgrade() -> None:
|
|
conn = op.get_bind()
|
|
|
|
# ── PART 1: Per-platform corrections requiring filesystem IO ─────
|
|
# SubscribeStar, HentaiFoundry, Discord all need fields from the
|
|
# sidecar to construct the right post_url. We walk each Post's
|
|
# related ImageRecord.path to find the sidecar, read it, derive,
|
|
# and update.
|
|
targets = conn.execute(text("""
|
|
SELECT p.id, p.external_post_id, p.post_url, s.platform
|
|
FROM post p
|
|
JOIN source s ON s.id = p.source_id
|
|
WHERE s.platform IN ('subscribestar', 'hentaifoundry', 'discord')
|
|
""")).fetchall()
|
|
|
|
stats: dict[str, dict[str, int]] = {
|
|
plat: {"read": 0, "updated": 0, "no_sidecar": 0}
|
|
for plat in ("subscribestar", "hentaifoundry", "discord")
|
|
}
|
|
for post_row in targets:
|
|
plat = post_row.platform
|
|
path = _first_attachment_path(conn, post_row.id)
|
|
if not path:
|
|
stats[plat]["no_sidecar"] += 1
|
|
continue
|
|
sidecar = _find_sidecar(Path(path))
|
|
if sidecar is None:
|
|
stats[plat]["no_sidecar"] += 1
|
|
continue
|
|
try:
|
|
data = json.loads(sidecar.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
stats[plat]["no_sidecar"] += 1
|
|
continue
|
|
stats[plat]["read"] += 1
|
|
|
|
new_epid = post_row.external_post_id
|
|
new_url = None
|
|
if plat == "subscribestar":
|
|
pid = _str_id(data.get("post_id"))
|
|
if pid:
|
|
new_epid = pid
|
|
new_url = f"https://www.subscribestar.com/posts/{pid}"
|
|
elif plat == "hentaifoundry":
|
|
user = _str_field(data.get("user")) or _str_field(data.get("artist"))
|
|
idx = _str_id(data.get("index"))
|
|
if user and idx:
|
|
new_url = f"https://www.hentai-foundry.com/pictures/user/{user}/{idx}"
|
|
elif plat == "discord":
|
|
sid = _str_id(data.get("server_id"))
|
|
cid = _str_id(data.get("channel_id"))
|
|
mid = _str_id(data.get("message_id"))
|
|
if sid and cid and mid:
|
|
new_url = f"https://discord.com/channels/{sid}/{cid}/{mid}"
|
|
|
|
# Idempotent: skip if nothing changed.
|
|
if new_epid == post_row.external_post_id and new_url == post_row.post_url:
|
|
continue
|
|
conn.execute(
|
|
text("""
|
|
UPDATE post
|
|
SET external_post_id = :epid, post_url = :url
|
|
WHERE id = :id
|
|
"""),
|
|
{"epid": new_epid, "url": new_url, "id": post_row.id},
|
|
)
|
|
stats[plat]["updated"] += 1
|
|
|
|
for plat, s in stats.items():
|
|
print(
|
|
f"0025: {plat} — read {s['read']} sidecars, "
|
|
f"updated {s['updated']} Posts, "
|
|
f"{s['no_sidecar']} Posts had no resolvable sidecar"
|
|
)
|
|
|
|
# ── PART 2: Merge SubscribeStar fragments now sharing epid ───────
|
|
# After Part 1, each group of Posts under one source with the SAME
|
|
# new external_post_id is a fragment-set of the same actual post.
|
|
# Merge to one canonical row. Pre-handle the same ImageProvenance
|
|
# collision pattern as alembic 0022 (uq_image_provenance_image_post).
|
|
fragment_groups = conn.execute(text("""
|
|
SELECT p.source_id, p.external_post_id,
|
|
ARRAY_AGG(p.id ORDER BY p.id ASC) AS post_ids
|
|
FROM post p
|
|
JOIN source s ON s.id = p.source_id
|
|
WHERE s.platform = 'subscribestar'
|
|
AND p.external_post_id IS NOT NULL
|
|
GROUP BY p.source_id, p.external_post_id
|
|
HAVING COUNT(*) > 1
|
|
""")).fetchall()
|
|
|
|
merged = 0
|
|
for grp in fragment_groups:
|
|
post_ids = list(grp.post_ids)
|
|
keep_id, *drop_ids = post_ids
|
|
for drop_id in drop_ids:
|
|
# Pre-DELETE colliding ImageProvenance under drop_ that
|
|
# already exist under keep (alembic 0022 banked the pattern).
|
|
conn.execute(
|
|
text("""
|
|
DELETE FROM image_provenance
|
|
WHERE post_id = :drop_
|
|
AND image_record_id IN (
|
|
SELECT image_record_id FROM image_provenance
|
|
WHERE post_id = :keep
|
|
)
|
|
"""),
|
|
{"keep": keep_id, "drop_": drop_id},
|
|
)
|
|
conn.execute(
|
|
text("""
|
|
UPDATE image_provenance SET post_id = :keep
|
|
WHERE post_id = :drop_
|
|
"""),
|
|
{"keep": keep_id, "drop_": drop_id},
|
|
)
|
|
conn.execute(
|
|
text("""
|
|
UPDATE image_record SET primary_post_id = :keep
|
|
WHERE primary_post_id = :drop_
|
|
"""),
|
|
{"keep": keep_id, "drop_": drop_id},
|
|
)
|
|
conn.execute(
|
|
text("""
|
|
UPDATE post_attachment SET post_id = :keep
|
|
WHERE post_id = :drop_
|
|
"""),
|
|
{"keep": keep_id, "drop_": drop_id},
|
|
)
|
|
conn.execute(
|
|
text("DELETE FROM post WHERE id = :drop_"),
|
|
{"drop_": drop_id},
|
|
)
|
|
merged += 1
|
|
print(f"0025: subscribestar — merged {merged} duplicate Post fragments")
|
|
|
|
# ── PART 3: Pixiv post_url backfill (pure SQL) ───────────────────
|
|
# Pixiv's external_post_id is already correct (gallery-dl's `id` is
|
|
# the post id). Only post_url needs derivation: replace anything
|
|
# under i.pximg.net (the file URL) with the /artworks/<id> permalink.
|
|
pixiv_updated = conn.execute(text("""
|
|
UPDATE post p
|
|
SET post_url = 'https://www.pixiv.net/artworks/' || p.external_post_id
|
|
FROM source s
|
|
WHERE p.source_id = s.id
|
|
AND s.platform = 'pixiv'
|
|
AND p.external_post_id IS NOT NULL
|
|
AND (p.post_url IS NULL
|
|
OR p.post_url LIKE 'https://i.pximg.net/%'
|
|
OR p.post_url LIKE 'http://i.pximg.net/%')
|
|
""")).rowcount
|
|
print(f"0025: pixiv — backfilled post_url on {pixiv_updated} Posts")
|
|
|
|
|
|
def _first_attachment_path(conn, post_id: int) -> str | None:
|
|
"""Return any ImageRecord.path attached to this post (via
|
|
ImageProvenance). Lowest-id row keeps the migration deterministic
|
|
so re-running on the same DB picks the same sidecar."""
|
|
row = conn.execute(
|
|
text("""
|
|
SELECT ir.path
|
|
FROM image_provenance ip
|
|
JOIN image_record ir ON ir.id = ip.image_record_id
|
|
WHERE ip.post_id = :pid
|
|
ORDER BY ip.id ASC
|
|
LIMIT 1
|
|
"""),
|
|
{"pid": post_id},
|
|
).first()
|
|
return row[0] if row else None
|
|
|
|
|
|
def downgrade() -> None:
|
|
# Lossy: external_post_id values were overwritten with the correct
|
|
# post_id; original per-attachment ids weren't preserved. Post-merge
|
|
# also deleted drop rows. No safe restore. To roll back the schema
|
|
# invariant, fork from 0024 and re-run sidecar imports.
|
|
pass
|