"""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 `` 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 canonical_subdir(subdir: str, artist_slug: str | None) -> str: """`subdir` with its TOP-LEVEL segment replaced by the artist's slug. canonical_subdir("Conto/patreon", "conto") -> "conto/patreon" canonical_subdir("Conto", "conto") -> "conto" canonical_subdir("Conto/patreon", None) -> "Conto/patreon" canonical_subdir("", "conto") -> "" `derive_subdir` mirrors the IMPORT tree's folder names verbatim, so a filesystem import out of `/import/Conto/...` used to write `/Conto/...` while the download path wrote `/conto/...` for the very same Artist row. One artist, two directories, forever — 57 such families had accumulated by 2026-09-21, and the database never had duplicate artists at all (milestone #421). The slug is the canonical name because it is the Artist row's own identifier: it is what `/api/artists` reports, what the ingesters already write, and the one spelling that cannot vary with how a folder happened to be capitalised on the way in. Two deliberate pass-throughs. NO artist resolved means there is nothing authoritative to canonicalise against, and an EMPTY subdir is a file landing at the images root — those have no artist folder to correct, and what becomes of them is its own decision (task #4247), not a side effect of this helper. """ if not artist_slug or not subdir: return subdir return str(Path(artist_slug, *Path(subdir).parts[1:])) def hash_suffixed_name(stem: str, sha256_hex: str, ext: str) -> str: """Builds 'stem__'. 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]