@@ -79,6 +79,12 @@ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
|
|||||||
VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv"}
|
VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv"}
|
||||||
ALL_EXTS = IMAGE_EXTS | VIDEO_EXTS
|
ALL_EXTS = IMAGE_EXTS | VIDEO_EXTS
|
||||||
|
|
||||||
|
# A "high-resolution files" pack often wraps per-chapter .rar/.zip inside one
|
||||||
|
# outer archive (incase #718) — recurse into nested archives so their images
|
||||||
|
# land instead of being silently dropped. Cap the depth so a maliciously
|
||||||
|
# deep-nested archive can't recurse forever; the bomb-probe re-runs per level.
|
||||||
|
_ARCHIVE_MAX_DEPTH = 3
|
||||||
|
|
||||||
|
|
||||||
def is_supported(path: Path) -> bool:
|
def is_supported(path: Path) -> bool:
|
||||||
return path.suffix.lower() in ALL_EXTS
|
return path.suffix.lower() in ALL_EXTS
|
||||||
@@ -491,17 +497,17 @@ class Importer:
|
|||||||
artist_use = artist if artist is not None else self._resolve_artist(source)
|
artist_use = artist if artist is not None else self._resolve_artist(source)
|
||||||
post = self._post_for_sidecar(source, artist_use)
|
post = self._post_for_sidecar(source, artist_use)
|
||||||
member_ids: list[int] = []
|
member_ids: list[int] = []
|
||||||
member_total = 0
|
# Per-outcome tally so the "no images" reason names the ACTUAL cause
|
||||||
with extract_archive(source) as members:
|
# (#718): nested-archive packs, all-deduped (benign), unsupported formats,
|
||||||
for _name, member_path in members:
|
# or failed/corrupt members — instead of one catch-all string.
|
||||||
member_total += 1
|
counts = {
|
||||||
if not is_supported(member_path):
|
"media": 0, "deduped": 0, "unsupported": 0,
|
||||||
continue # non-media preserved via the stored archive
|
"failed": 0, "nested": 0, "nested_rejected": 0,
|
||||||
res = self._import_media(
|
}
|
||||||
member_path, source, explicit_source=source_row,
|
self._collect_archive_members(
|
||||||
|
source, attribution=source, source_row=source_row,
|
||||||
|
depth=0, member_ids=member_ids, counts=counts,
|
||||||
)
|
)
|
||||||
if res.status in ("imported", "superseded") and res.image_id:
|
|
||||||
member_ids.append(res.image_id)
|
|
||||||
# Preserve the archive itself (links to the same Post/Artist).
|
# Preserve the archive itself (links to the same Post/Artist).
|
||||||
self._capture_attachment(
|
self._capture_attachment(
|
||||||
source, post=post, artist=artist_use, resolved=True
|
source, post=post, artist=artist_use, resolved=True
|
||||||
@@ -511,20 +517,93 @@ class Importer:
|
|||||||
status="imported", image_id=member_ids[0],
|
status="imported", image_id=member_ids[0],
|
||||||
member_image_ids=member_ids,
|
member_image_ids=member_ids,
|
||||||
)
|
)
|
||||||
# No images landed — surface WHY so a post showing "no images" beside an
|
# No NEW images landed. Name WHY precisely so a post showing "no images"
|
||||||
# archive is diagnosable instead of silent. Zero members usually means
|
# beside an archive is diagnosable — and DON'T flag the benign all-deduped
|
||||||
# the extractor backend is missing/failed (unar for rar, py7zr for 7z)
|
# case as a problem: those images already exist in the library and were
|
||||||
# or the file is corrupt; non-zero-but-no-images means it held only
|
# re-linked to this post (enrich-on-duplicate), so the post DOES show them.
|
||||||
# non-media files.
|
if counts["media"] == 0 and counts["nested"] == 0:
|
||||||
|
reason = ("archive yielded no members (unsupported/corrupt, or the "
|
||||||
|
"extractor backend failed)")
|
||||||
|
elif counts["deduped"] and not (
|
||||||
|
counts["unsupported"] or counts["failed"] or counts["nested_rejected"]
|
||||||
|
):
|
||||||
|
log.info(
|
||||||
|
"%s: all %d image member(s) already in library (deduped + linked "
|
||||||
|
"to this post) — archive preserved, no new import",
|
||||||
|
source.name, counts["deduped"],
|
||||||
|
)
|
||||||
|
return ImportResult(status="attached")
|
||||||
|
else:
|
||||||
reason = (
|
reason = (
|
||||||
"archive extracted but held no supported image/video members"
|
f"no new images — media={counts['media']} deduped={counts['deduped']} "
|
||||||
if member_total
|
f"unsupported/non-media={counts['unsupported']} failed={counts['failed']} "
|
||||||
else "archive yielded no members (unsupported/corrupt, or the "
|
f"nested={counts['nested']} nested-rejected={counts['nested_rejected']}"
|
||||||
"extractor backend failed)"
|
|
||||||
)
|
)
|
||||||
log.warning("%s: %s", source.name, reason)
|
log.warning("%s: %s", source.name, reason)
|
||||||
return ImportResult(status="attached", error=reason)
|
return ImportResult(status="attached", error=reason)
|
||||||
|
|
||||||
|
def _collect_archive_members(
|
||||||
|
self, archive_path: Path, *, attribution: Path,
|
||||||
|
source_row: Source | None, depth: int,
|
||||||
|
member_ids: list[int], counts: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Extract `archive_path` and import its image/video members, RECURSING
|
||||||
|
into nested archives (#718). Members attribute to `attribution` — the
|
||||||
|
OUTER archive's path, so its sidecar resolves them to the right Post even
|
||||||
|
when they came from a nested archive. Depth-capped + bomb-probed per
|
||||||
|
nested level. Mutates `member_ids` and `counts` in place.
|
||||||
|
|
||||||
|
extract_archive is fail-soft, but each level is wrapped so one bad nested
|
||||||
|
archive can't abort the whole import."""
|
||||||
|
try:
|
||||||
|
with extract_archive(archive_path) as members:
|
||||||
|
for _name, member_path in members:
|
||||||
|
if not member_path.is_file():
|
||||||
|
continue # directory entries etc. — not media
|
||||||
|
if is_archive(member_path):
|
||||||
|
counts["nested"] += 1
|
||||||
|
if depth + 1 > _ARCHIVE_MAX_DEPTH:
|
||||||
|
counts["nested_rejected"] += 1
|
||||||
|
log.warning(
|
||||||
|
"nested archive past depth cap %d, skipped: %s",
|
||||||
|
_ARCHIVE_MAX_DEPTH, member_path.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
probe = safe_probe.probe_archive(member_path)
|
||||||
|
if not probe.ok:
|
||||||
|
counts["nested_rejected"] += 1
|
||||||
|
log.warning(
|
||||||
|
"nested archive rejected (%s): %s",
|
||||||
|
probe.reason, member_path.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
self._collect_archive_members(
|
||||||
|
member_path, attribution=attribution,
|
||||||
|
source_row=source_row, depth=depth + 1,
|
||||||
|
member_ids=member_ids, counts=counts,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
counts["media"] += 1
|
||||||
|
if not is_supported(member_path):
|
||||||
|
counts["unsupported"] += 1
|
||||||
|
continue # non-media preserved via the stored archive
|
||||||
|
res = self._import_media(
|
||||||
|
member_path, attribution, explicit_source=source_row,
|
||||||
|
)
|
||||||
|
if res.status in ("imported", "superseded") and res.image_id:
|
||||||
|
member_ids.append(res.image_id)
|
||||||
|
elif res.status == "skipped" and res.skip_reason in (
|
||||||
|
SkipReason.duplicate_hash, SkipReason.duplicate_phash
|
||||||
|
):
|
||||||
|
counts["deduped"] += 1
|
||||||
|
else:
|
||||||
|
counts["failed"] += 1
|
||||||
|
except Exception as exc: # noqa: BLE001 — defensive per level; keep going
|
||||||
|
log.warning(
|
||||||
|
"archive extraction failed for %s (depth %d): %s",
|
||||||
|
archive_path.name, depth, exc,
|
||||||
|
)
|
||||||
|
|
||||||
def _import_media(
|
def _import_media(
|
||||||
self, source: Path, attribution_path: Path,
|
self, source: Path, attribution_path: Path,
|
||||||
*, explicit_source: Source | None = None,
|
*, explicit_source: Source | None = None,
|
||||||
|
|||||||
@@ -93,6 +93,77 @@ def test_archive_imports_members_and_stores_archive(importer, import_layout):
|
|||||||
assert att.post_id == post.id
|
assert att.post_id == post.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_archive_members_imported(importer, import_layout):
|
||||||
|
"""#718: a 'high-res' pack that wraps a nested archive must still import the
|
||||||
|
nested images and link them to the post — not silently drop them (incase's
|
||||||
|
per-chapter .rar inside an outer 'High Resolution files' pack)."""
|
||||||
|
import_root, _ = import_layout
|
||||||
|
importer.settings.phash_threshold = 0 # v/h splits are distinct only at 0
|
||||||
|
inner = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(inner, "w") as zf:
|
||||||
|
zf.writestr("p1.jpg", _split_bytes("v"))
|
||||||
|
zf.writestr("p2.jpg", _split_bytes("h"))
|
||||||
|
arc = import_root / "Nessie" / "hr.cbz"
|
||||||
|
arc.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zipfile.ZipFile(arc, "w") as zf:
|
||||||
|
zf.writestr("chapter.zip", inner.getvalue()) # nested archive, no direct media
|
||||||
|
arc.with_suffix(".cbz.json").write_text(json.dumps(
|
||||||
|
{"category": "patreon", "id": "900", "title": "HR Pack"}))
|
||||||
|
|
||||||
|
r = importer.import_one(arc)
|
||||||
|
assert r.status == "imported"
|
||||||
|
assert len(r.member_image_ids) == 2 # both nested images surfaced
|
||||||
|
|
||||||
|
assert importer.session.execute(
|
||||||
|
select(func.count()).select_from(ImageRecord)
|
||||||
|
).scalar_one() == 2
|
||||||
|
post = importer.session.execute(select(Post)).scalar_one()
|
||||||
|
provs = importer.session.execute(
|
||||||
|
select(func.count()).select_from(ImageProvenance)
|
||||||
|
).scalar_one()
|
||||||
|
assert provs == 2 # both nested members linked to the one outer post
|
||||||
|
# The outer pack is still preserved as an attachment.
|
||||||
|
att = importer.session.execute(select(PostAttachment)).scalar_one()
|
||||||
|
assert att.original_filename == "hr.cbz"
|
||||||
|
assert att.post_id == post.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_archive_all_deduped_is_benign_not_flagged(importer, import_layout):
|
||||||
|
"""#718 reason-string fix: when every image in an archive already exists
|
||||||
|
(cross-posted), the images re-link to the new post and the archive is NOT
|
||||||
|
flagged as an unextracted-archive problem (no error set)."""
|
||||||
|
import_root, _ = import_layout
|
||||||
|
importer.settings.phash_threshold = 0
|
||||||
|
pv, ph = _split_bytes("v"), _split_bytes("h")
|
||||||
|
first = import_root / "Dup" / "a.cbz"
|
||||||
|
first.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zipfile.ZipFile(first, "w") as zf:
|
||||||
|
zf.writestr("a.jpg", pv)
|
||||||
|
zf.writestr("b.jpg", ph)
|
||||||
|
first.with_suffix(".cbz.json").write_text(json.dumps(
|
||||||
|
{"category": "patreon", "id": "111", "title": "First"}))
|
||||||
|
assert importer.import_one(first).status == "imported"
|
||||||
|
|
||||||
|
second = import_root / "Dup" / "b.cbz" # different post, same two images
|
||||||
|
with zipfile.ZipFile(second, "w") as zf:
|
||||||
|
zf.writestr("a.jpg", pv)
|
||||||
|
zf.writestr("b.jpg", ph)
|
||||||
|
second.with_suffix(".cbz.json").write_text(json.dumps(
|
||||||
|
{"category": "patreon", "id": "222", "title": "Second"}))
|
||||||
|
|
||||||
|
r = importer.import_one(second)
|
||||||
|
assert r.status == "attached"
|
||||||
|
assert r.error is None # benign all-deduped — NOT a flagged problem
|
||||||
|
|
||||||
|
assert importer.session.execute(
|
||||||
|
select(func.count()).select_from(ImageRecord)
|
||||||
|
).scalar_one() == 2 # no new records
|
||||||
|
provs = importer.session.execute(
|
||||||
|
select(func.count()).select_from(ImageProvenance)
|
||||||
|
).scalar_one()
|
||||||
|
assert provs == 4 # 2 images × 2 posts (enrich-on-duplicate)
|
||||||
|
|
||||||
|
|
||||||
def test_corrupt_archive_still_stored(importer, import_layout):
|
def test_corrupt_archive_still_stored(importer, import_layout):
|
||||||
import_root, _ = import_layout
|
import_root, _ = import_layout
|
||||||
arc = import_root / "Bob" / "broken.zip"
|
arc = import_root / "Bob" / "broken.zip"
|
||||||
|
|||||||
Reference in New Issue
Block a user