CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 21s
CI and images / frontend-build (push) Successful in 21s
CI and images / backend-lint-and-test (push) Successful in 45s
CI and images / integration (push) Successful in 2m31s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 4s
CI and images / build-web (push) Successful in 2m1s
CI and images / smoke-web (push) Successful in 49s
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
Most archive attachments never had their images imported. The re-extract action skipped archives without a post, and nothing kept game builds out of an extraction. - An archive holding executables, libraries, or game-engine data (Ren'Py, Unity, Godot, RPG Maker, Python builds) is kept as an attachment and not extracted. The same holds for one nested inside an art pack. - Re-extract now reaches archives imported from a folder without a post. Their images go to the artist, in a folder named after the archive. - Re-extract passes over archives whose members are already recorded. - Re-extract stages each archive by symlink instead of copying it. - The card says what the action does now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
4.7 KiB
Python
138 lines
4.7 KiB
Python
"""Best-effort archive member extraction (filesystem-import aid).
|
|
|
|
zip/cbz via stdlib zipfile; rar via rarfile (needs an unrar/bsdtar
|
|
binary); 7z via py7zr (pure-Python). NEVER raises: on any failure the
|
|
context yields an empty list, and the caller still preserves the archive
|
|
file itself as a PostAttachment, so nothing is lost.
|
|
"""
|
|
|
|
import logging
|
|
import zipfile
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
ARCHIVE_EXTS = {".zip", ".cbz", ".rar", ".7z"}
|
|
|
|
# Magic-byte signatures, so an archive with a mangled / extension-less filename
|
|
# is still recognised. Patreon attachment download URLs sanitize to names like
|
|
# `01_https___www.patreon.com_media-u_v3_131083093`, whose `Path.suffix` is junk
|
|
# (`.com_media-u_v3_131083093`), never `.zip` — an extension-only gate filed
|
|
# those as opaque PostAttachments and NEVER extracted them (operator-flagged
|
|
# 2026-06-06). Detection is by extension first (cheap), then header sniff.
|
|
_RAR_MAGIC = b"Rar!\x1a\x07"
|
|
_7Z_MAGIC = b"7z\xbc\xaf\x27\x1c"
|
|
|
|
|
|
def detect_archive_format(path: Path) -> str | None:
|
|
"""Return "zip" | "rar" | "7z" for an archive, else None.
|
|
|
|
Trusts a known extension first, then falls back to magic-byte sniffing so a
|
|
mis-named or extension-less archive is still handled. (zip covers .cbz too.)
|
|
"""
|
|
ext = Path(path).suffix.lower()
|
|
if ext in (".zip", ".cbz"):
|
|
return "zip"
|
|
if ext == ".rar":
|
|
return "rar"
|
|
if ext == ".7z":
|
|
return "7z"
|
|
try:
|
|
if zipfile.is_zipfile(path):
|
|
return "zip"
|
|
with open(path, "rb") as fh:
|
|
head = fh.read(8)
|
|
except OSError:
|
|
return None
|
|
if head.startswith(_RAR_MAGIC):
|
|
return "rar"
|
|
if head.startswith(_7Z_MAGIC):
|
|
return "7z"
|
|
return None
|
|
|
|
|
|
def is_archive(path: Path) -> bool:
|
|
return detect_archive_format(path) is not None
|
|
|
|
|
|
# Files that only a program ships: executables and libraries, and the packed
|
|
# data and compiled scripts of the common game engines (Ren'Py, Unity, Godot,
|
|
# RPG Maker, Python builds). An archive holding any of them is a game or app
|
|
# build. Its images are sprites, UI and CGs bundled for the program, not art
|
|
# for the library, so the archive is kept whole as an attachment.
|
|
SOFTWARE_EXTS = frozenset({
|
|
".exe", ".msi", ".dll", ".so", ".dylib", ".apk",
|
|
".rpa", ".rpyc", ".rpy", ".pyc", ".pyo",
|
|
".pak", ".pck", ".unity3d", ".assets", ".ress",
|
|
".rgssad", ".rgss2a", ".rgss3a", ".rxdata", ".rvdata", ".rvdata2",
|
|
})
|
|
|
|
|
|
def member_names(path: Path) -> list[str]:
|
|
"""Every member name in the archive, read from its directory without
|
|
decompressing anything. Empty when the archive can't be listed."""
|
|
try:
|
|
fmt = detect_archive_format(path)
|
|
if fmt == "zip":
|
|
with zipfile.ZipFile(path) as zf:
|
|
return zf.namelist()
|
|
if fmt == "rar":
|
|
import rarfile
|
|
|
|
with rarfile.RarFile(path) as rf:
|
|
return rf.namelist()
|
|
if fmt == "7z":
|
|
import py7zr
|
|
|
|
with py7zr.SevenZipFile(path, "r") as zf:
|
|
return zf.getnames()
|
|
except Exception as exc:
|
|
log.warning("archive listing failed for %s: %s", path, exc)
|
|
return []
|
|
|
|
|
|
def software_marker(path: Path) -> str | None:
|
|
"""The first member that marks the archive as a software build, or None."""
|
|
for name in member_names(path):
|
|
if Path(name).suffix.lower() in SOFTWARE_EXTS:
|
|
return name
|
|
return None
|
|
|
|
|
|
@contextmanager
|
|
def extract_archive(path: Path):
|
|
"""Context manager yielding a list of (member_name, extracted_path)
|
|
for every regular-file member. Temp dir is removed on exit. Members
|
|
are only ever under the temp dir (libs' safe extractall + rglob from
|
|
base). Fail-soft: any error → yields []."""
|
|
tmp = TemporaryDirectory(prefix="fc_arch_")
|
|
base = Path(tmp.name)
|
|
members: list[tuple[str, Path]] = []
|
|
try:
|
|
try:
|
|
fmt = detect_archive_format(path)
|
|
if fmt == "zip":
|
|
with zipfile.ZipFile(path) as zf:
|
|
zf.extractall(base)
|
|
elif fmt == "rar":
|
|
import rarfile
|
|
|
|
with rarfile.RarFile(path) as rf:
|
|
rf.extractall(base)
|
|
elif fmt == "7z":
|
|
import py7zr
|
|
|
|
with py7zr.SevenZipFile(path, "r") as zf:
|
|
zf.extractall(base)
|
|
for p in sorted(base.rglob("*")):
|
|
if p.is_file():
|
|
members.append((str(p.relative_to(base)), p))
|
|
except Exception as exc:
|
|
log.warning("archive extract failed for %s: %s", path, exc)
|
|
members = []
|
|
yield members
|
|
finally:
|
|
tmp.cleanup()
|