Files
FabledCurator/backend/app/utils/sidecar.py
T

124 lines
3.8 KiB
Python

"""Minimal gallery-dl sidecar parsing (one-time filesystem-import aid).
No per-platform branching: a small common key set with fallbacks; the
full JSON is kept in raw so anything unmapped is recoverable later.
"""
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
@dataclass(frozen=True)
class SidecarData:
platform: str | None
external_post_id: str | None
post_url: str | None
post_title: str | None
description: str | None
attachment_count: int | None
post_date: datetime | None
raw: dict
# gallery-dl prefixes media filenames with `NN_` for in-post ordering
# (`01_HOLLOW-ICHIGO.png`, `02_HOLOW ICHIGO.zip`) but writes the sidecar
# under the attachment's stem WITHOUT that ordering prefix
# (`HOLLOW-ICHIGO.json`). Strip the prefix when looking for sidecars.
# Confirmed against real Patreon downloads 2026-05-26 — without this
# strip, every gallery-dl post-level sidecar was invisible to FC since
# FC-3 shipped (24 deep-refresh calls produced 0 Posts in operator's DB).
_NUMBERING_PREFIX = re.compile(r"^\d+_(.+)$")
def find_sidecar(media: Path) -> Path | None:
# Attachment-level sidecars (image.jpg.json, image.json).
for cand in (media.with_suffix(".json"), Path(str(media) + ".json")):
if cand.is_file():
return cand
# gallery-dl post-numbered convention: strip the `NN_` prefix from
# the stem and look for that.json in the same directory.
m = _NUMBERING_PREFIX.match(media.stem)
if m:
cand = media.parent / f"{m.group(1)}.json"
if cand.is_file():
return cand
return None
def _first_str(data: dict, keys: tuple[str, ...]) -> str | None:
for k in keys:
v = data.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
return None
def _parse_date(v) -> datetime | None:
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
try:
return datetime.fromtimestamp(v, tz=UTC)
except (OverflowError, OSError, ValueError):
return None
if isinstance(v, str):
s = v.strip()
if not s:
return None
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(s)
except ValueError:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
return None
def parse_sidecar(data: dict) -> SidecarData:
if not isinstance(data, dict):
data = {}
cat = data.get("category")
platform = cat if isinstance(cat, str) and cat.strip() else None
external_post_id = None
for k in ("id", "post_id", "index", "message_id"):
v = data.get(k)
if isinstance(v, bool):
continue
if isinstance(v, (str, int)) and str(v).strip():
external_post_id = str(v)
break
pc = data.get("page_count")
if isinstance(pc, bool):
attachment_count = None
elif isinstance(pc, int) and pc > 0:
attachment_count = pc
elif isinstance(data.get("images"), list):
attachment_count = len(data["images"])
else:
attachment_count = None
post_date = None
for k in ("published_at", "created_at", "date", "create_date",
"timestamp"):
if k in data:
post_date = _parse_date(data[k])
if post_date is not None:
break
return SidecarData(
platform=platform,
external_post_id=external_post_id,
post_url=_first_str(data, ("url", "post_url")),
post_title=_first_str(data, ("title",)),
description=_first_str(data, ("content", "description", "caption")),
attachment_count=attachment_count,
post_date=post_date,
raw=data,
)