96c29c370b
Render a post body faithfully by serving our stored copies of inline images instead of hotlinking the public CDN. The join key is the CDN filehash (32-hex MD5) shared between a body <img src> and the media URL we downloaded (the same identity extract_media dedups by): - utils.paths.filehash_from_url — one source of truth for the extractor; patreon_client._filehash now delegates so capture- and render-time hashing cannot drift. - ImageRecord gains source_url (provenance) + source_filehash (indexed match key); migration 0051. - the per-media sidecar carries the file's source_url; the importer persists it (NULL-only) on the ImageRecord via _apply_sidecar. - post_feed_service.get_post remaps body <img src> -> /images/<path> for every inline image whose filehash maps to a stored image of THIS artist; unmatched / pre-Phase-2 images keep hotlinking. Pre-existing on-disk images have no filehash yet, so they fall back to hotlinking until re-downloaded; localization is forward-looking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
81 lines
3.0 KiB
Python
81 lines
3.0 KiB
Python
"""Filesystem path helpers — destination derivation, hash-suffixed names."""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
_MAX_EXT_LEN = 16
|
|
|
|
# A Patreon/gallery CDN URL embeds a 32-char hex (MD5) path segment that is the
|
|
# file's stable per-file identity — the same role gallery-dl's `_filehash`
|
|
# plays. It is the join key between a post body `<img src=CDN>` and the local
|
|
# copy we downloaded (extract_media dedups content vs gallery images by it), so
|
|
# this ONE extractor must be used for both capture-time persistence and
|
|
# render-time matching — they cannot be allowed to drift. Match the FIRST 32-hex
|
|
# run anywhere in the URL (path or query); real CDN URLs carry exactly one.
|
|
_FILEHASH_RE = re.compile(r"([0-9a-fA-F]{32})")
|
|
|
|
|
|
def filehash_from_url(url: str | None) -> str | None:
|
|
"""The 32-char hex (MD5) CDN identity segment of `url`, lowercased, or None
|
|
when the URL is empty / carries no such segment."""
|
|
if not url:
|
|
return None
|
|
match = _FILEHASH_RE.search(url)
|
|
return match.group(1).lower() if match else None
|
|
|
|
|
|
def safe_ext(name: str | Path) -> str:
|
|
"""Conservatively extract a short, alphanumeric file extension.
|
|
|
|
gallery-dl and Patreon CDN URLs produce basenames with URL-encoded
|
|
query-string artifacts, so `Path.suffix` can return 50+ chars of base64-ish
|
|
junk that blows bounded VARCHAR columns (e.g. PostAttachment.ext varchar(32)).
|
|
Accept only a suffix ≤16 chars whose post-dot characters are all alphanumeric;
|
|
otherwise return "" (no known extension). Operator-flagged 2026-05-25 — ONE
|
|
impl for the importer and the native Patreon client.
|
|
"""
|
|
suffix = Path(name).suffix.lower()
|
|
if not suffix or len(suffix) > _MAX_EXT_LEN:
|
|
return ""
|
|
if not all(c.isalnum() for c in suffix[1:]):
|
|
return ""
|
|
return suffix
|
|
|
|
|
|
def derive_subdir(source_path: Path, import_root: Path) -> str:
|
|
"""Returns the relative subdirectory of source_path under import_root.
|
|
|
|
The top-level folder name is treated as the 'artist' bucket. Nested
|
|
paths preserve hierarchy.
|
|
|
|
import_root=/import
|
|
source_path=/import/Alice/sub/x.png -> "Alice/sub"
|
|
source_path=/import/Alice/x.png -> "Alice"
|
|
source_path=/import/x.png -> ""
|
|
"""
|
|
try:
|
|
rel = source_path.parent.relative_to(import_root)
|
|
except ValueError:
|
|
return ""
|
|
return str(rel) if str(rel) != "." else ""
|
|
|
|
|
|
def hash_suffixed_name(stem: str, sha256_hex: str, ext: str) -> str:
|
|
"""Builds 'stem__<first10ofhash><ext>'.
|
|
|
|
Examples:
|
|
hash_suffixed_name("photo", "abcdef1234567890...", ".png")
|
|
-> "photo__abcdef1234.png"
|
|
"""
|
|
return f"{stem}__{sha256_hex[:10]}{ext}"
|
|
|
|
|
|
def derive_top_level_artist(source_path: Path, import_root: Path) -> str | None:
|
|
"""Returns the top-level folder name under import_root, or None if the
|
|
file is directly in import_root.
|
|
"""
|
|
subdir = derive_subdir(source_path, import_root)
|
|
if not subdir:
|
|
return None
|
|
return subdir.split("/", 1)[0]
|