fix(alembic 0025): include HF + Discord post_url backfill (no longer 'deferred to deep-scan')

Operator-flagged: the claim that 'a future deep-scan via the new parser
will fix HF and Discord post_url' was conditional on the operator
actually running a deep-scan, which they might not do for ages. Until
then HF posts stay at post_url=NULL (HF sidecars have no `url` field)
and Discord posts stay pointing at cdn.discordapp.com/attachments/...
(the file URL, not the message permalink).

The migration was already opening sidecar files for SubscribeStar.
Generalizing the loop to also handle HF and Discord is a tiny addition
that closes the gap without operator intervention.

Per-platform Part 1 logic now:
  subscribestar — read sidecar.post_id, overwrite external_post_id +
    post_url with the derived /posts/<post_id> permalink.
  hentaifoundry — read sidecar.user + .index, overwrite post_url with
    /pictures/user/<u>/<i>. external_post_id (= index) unchanged.
  discord — read sidecar.server_id + .channel_id + .message_id,
    overwrite post_url with the discord.com/channels/.../<m> triple.
    external_post_id (= message_id) unchanged.

Part 2 (SubscribeStar fragment merge) and Part 3 (pure-SQL Pixiv
post_url backfill) unchanged.

Posts whose related ImageRecord paths don't resolve on disk (orphan
filesystem state) are reported per-platform in the migration output —
those still need a future deep-scan, but the in-DB-with-on-disk-files
common case is now fully covered by the migration alone.
This commit is contained in:
2026-05-27 15:38:18 -04:00
parent bd3f996582
commit b7b313cc05
@@ -1,46 +1,48 @@
"""subscribestar: correct external_post_id + post_url, merge fragmented Posts """sidecar-audit followup: correct external_post_id + post_url across all platforms
Revision ID: 0025 Revision ID: 0025
Revises: 0024 Revises: 0024
Create Date: 2026-05-27 Create Date: 2026-05-27
Closes the operator-flagged 2026-05-27 SubscribeStar data-fragmentation Closes the operator-flagged 2026-05-27 sidecar audit findings. Three
issue: gallery-dl writes the per-attachment id in `id` and the actual data-correctness bugs across non-Patreon platforms had been silently
post id in `post_id`. FC's sidecar parser had `id` winning the corrupting Posts since FC-3 shipped; the parser fix (sidecar.py, same
external_post_id fallback chain, so every multi-image SubscribeStar post commit) addresses new imports. This migration cleans up existing rows.
was fragmented into N Post rows (one per attachment) in the database.
The parser fix (sidecar.py, same commit) reorders the chain so `post_id` Per-platform actions:
wins. This migration cleans up the existing fragmented data.
Strategy per SubscribeStar Post in the database: subscribestar — gallery-dl wrote the per-attachment id in `id` and
1. Find a related ImageRecord via ImageProvenance to get an on-disk the actual post id in `post_id`. FC's parser picked `id`, so every
path. Walk to its sidecar JSON (gallery-dl's NN_ numbering prefix multi-image SubscribeStar post was fragmented into N Post rows.
convention is honored). 1. For each SubscribeStar Post, read its sidecar (via the related
2. Read the sidecar; if it has `post_id`, that's the correct ImageRecord's on-disk path), pull `post_id`, overwrite
external_post_id. external_post_id and post_url.
3. Update Post.external_post_id (= post_id) and Post.post_url (= 2. Merge groups of Posts under one source that now share an
derived https://www.subscribestar.com/posts/<post_id>). external_post_id (fragments of the same actual post). Same
ImageProvenance pre-delete + repoint dance as alembic 0022.
After all rows are updated, every group of Posts under the same source hentaifoundry — sidecars have NO `url` field; `src` is the image
that share the same NEW external_post_id is a duplicate set — URL. FC's parser stored post_url=NULL. Read each HF Post's sidecar
fragments of the same actual post. Merge them: pick canonical (lowest for `user` + `index`, derive the canonical /pictures/user/<u>/<i>
id), repoint ImageProvenance + ImageRecord.primary_post_id + permalink. external_post_id (= `index`) was already correct.
PostAttachment to canonical, dedupe ImageProvenance against alembic
0021's UNIQUE, delete drop Posts.
Also: pure-SQL backfill of Pixiv post_url (Pixiv sidecars also write a discord — gallery-dl wrote the CDN attachment URL in `url`. FC's
file URL in `url`; the post permalink is /artworks/<id> and FC's parser parser stored that as post_url. Read each Discord Post's sidecar
now derives it). Pixiv external_post_id is already correct (gallery-dl's for the server/channel/message triple, derive the proper
`id` matches the URL pattern) so no per-row sidecar IO needed. discord.com/channels/.../<message> permalink. external_post_id (=
`message_id`) was already correct.
HF + Discord post_url backfills are skipped here — HF needs the user pixiv — pure-SQL backfill: replace any `i.pximg.net`-style URL on
field (not stored on Post directly), Discord needs server/channel Post.post_url with the derived `/artworks/<id>` permalink. Pixiv
triple. Both will eventually be corrected by a deep-scan that external_post_id (= `id`) was already correct; no sidecar IO
re-applies sidecars via the new parser. needed.
Idempotent: re-running on already-corrected data is a no-op (the post_id Idempotent: re-running on already-corrected data is a no-op (skips
in sidecar matches what's already in DB). 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 from __future__ import annotations
@@ -89,45 +91,79 @@ def _find_sidecar(media_path: Path) -> Path | None:
return None 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: def upgrade() -> None:
conn = op.get_bind() conn = op.get_bind()
# ── PART 1: SubscribeStar post_id correction ───────────────────── # ── PART 1: Per-platform corrections requiring filesystem IO ─────
ss_posts = conn.execute(text(""" # SubscribeStar, HentaiFoundry, Discord all need fields from the
SELECT p.id, p.external_post_id, p.source_id, p.post_url # 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 FROM post p
JOIN source s ON s.id = p.source_id JOIN source s ON s.id = p.source_id
WHERE s.platform = 'subscribestar' WHERE s.platform IN ('subscribestar', 'hentaifoundry', 'discord')
""")).fetchall() """)).fetchall()
sidecars_read = 0 stats: dict[str, dict[str, int]] = {
posts_updated = 0 plat: {"read": 0, "updated": 0, "no_sidecar": 0}
posts_missing_sidecar = 0 for plat in ("subscribestar", "hentaifoundry", "discord")
for post_row in ss_posts: }
for post_row in targets:
plat = post_row.platform
path = _first_attachment_path(conn, post_row.id) path = _first_attachment_path(conn, post_row.id)
if not path: if not path:
posts_missing_sidecar += 1 stats[plat]["no_sidecar"] += 1
continue continue
sidecar = _find_sidecar(Path(path)) sidecar = _find_sidecar(Path(path))
if sidecar is None: if sidecar is None:
posts_missing_sidecar += 1 stats[plat]["no_sidecar"] += 1
continue continue
try: try:
data = json.loads(sidecar.read_text(encoding="utf-8")) data = json.loads(sidecar.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
posts_missing_sidecar += 1 stats[plat]["no_sidecar"] += 1
continue continue
sidecars_read += 1 stats[plat]["read"] += 1
post_id = data.get("post_id")
if not isinstance(post_id, (str, int)) or isinstance(post_id, bool): new_epid = post_row.external_post_id
continue new_url = None
post_id_str = str(post_id).strip() if plat == "subscribestar":
if not post_id_str: pid = _str_id(data.get("post_id"))
continue if pid:
new_url = f"https://www.subscribestar.com/posts/{post_id_str}" new_epid = pid
# Idempotent: skip if already correct. new_url = f"https://www.subscribestar.com/posts/{pid}"
if (post_row.external_post_id == post_id_str elif plat == "hentaifoundry":
and post_row.post_url == new_url): 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 continue
conn.execute( conn.execute(
text(""" text("""
@@ -135,15 +171,16 @@ def upgrade() -> None:
SET external_post_id = :epid, post_url = :url SET external_post_id = :epid, post_url = :url
WHERE id = :id WHERE id = :id
"""), """),
{"epid": post_id_str, "url": new_url, "id": post_row.id}, {"epid": new_epid, "url": new_url, "id": post_row.id},
) )
posts_updated += 1 stats[plat]["updated"] += 1
print( for plat, s in stats.items():
f"0025: subscribestar — read {sidecars_read} sidecars, " print(
f"updated {posts_updated} Posts, " f"0025: {plat} — read {s['read']} sidecars, "
f"{posts_missing_sidecar} Posts had no resolvable sidecar" f"updated {s['updated']} Posts, "
) f"{s['no_sidecar']} Posts had no resolvable sidecar"
)
# ── PART 2: Merge SubscribeStar fragments now sharing epid ─────── # ── PART 2: Merge SubscribeStar fragments now sharing epid ───────
# After Part 1, each group of Posts under one source with the SAME # After Part 1, each group of Posts under one source with the SAME