f33808b977
The core writes the post record BEFORE extract_media, but the ugoira frame delays were only memoized DURING extract_media — so write_post_record never saw them and ugoira_frames was always empty in the record. Extract a memoized _ugoira_meta (frames + zip url share ONE /v1/ugoira/metadata call regardless of order) and inject client.fetch_ugoira_frames into the downloader (mirrors Patreon's content_fetcher) so write_post_record populates the frames itself. Zero extra API calls — the fetch is shared/memoized with extract_media. A recapture now backfills the timings onto existing ugoira posts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
310 lines
11 KiB
Python
310 lines
11 KiB
Python
"""Unit tests for PixivDownloader — no network, no DB.
|
|
|
|
The HTTP layer is stubbed via the `session` seam (a fake session whose `.get`
|
|
returns canned streaming bytes). Media items come from the real
|
|
PixivClient.extract_media over the shared fixture page, so the downloader is
|
|
exercised against the exact shapes the client produces.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
from backend.app.services.pixiv_client import MediaItem, PixivClient
|
|
from backend.app.services.pixiv_downloader import PixivDownloader
|
|
|
|
_FIXTURE = Path(__file__).parent / "fixtures" / "pixiv_user_illusts_page1.json"
|
|
|
|
# Minimal valid PNG so the file_validator passes on the happy path.
|
|
_PNG_HEAD = b"\x89PNG\r\n\x1a\n"
|
|
_PNG_TAIL = b"\x00\x00\x00\x00IEND\xaeB`\x82"
|
|
_PNG_BYTES = _PNG_HEAD + (b"\x00" * 16) + _PNG_TAIL
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, payload: bytes, status_code: int = 200, headers=None):
|
|
self._payload = payload
|
|
self.status_code = status_code
|
|
self.headers = headers or {}
|
|
|
|
def raise_for_status(self):
|
|
if self.status_code >= 400:
|
|
raise requests.HTTPError(f"HTTP {self.status_code}")
|
|
return None
|
|
|
|
def iter_content(self, chunk_size=65536):
|
|
for i in range(0, len(self._payload), chunk_size):
|
|
yield self._payload[i : i + chunk_size]
|
|
|
|
|
|
class _FakeSession:
|
|
"""Records GETs and returns canned bytes (per-URL, default _PNG_BYTES)."""
|
|
|
|
def __init__(self, payloads: dict[str, bytes] | None = None):
|
|
self.payloads = payloads or {}
|
|
self.calls: list[str] = []
|
|
|
|
def get(self, url, stream=False, timeout=None, headers=None):
|
|
self.calls.append(url)
|
|
return _FakeResponse(self.payloads.get(url, _PNG_BYTES))
|
|
|
|
|
|
def _client():
|
|
# Parsing only — extract_media for non-ugoira works never issues a request.
|
|
return PixivClient("tok", session=_FakeSession())
|
|
|
|
|
|
def _post_and_media(work_id):
|
|
page = json.loads(_FIXTURE.read_text())
|
|
client = _client()
|
|
for work in page["illusts"]:
|
|
if work["id"] == work_id:
|
|
post = client._normalize(work)
|
|
return post, client.extract_media(post, {})
|
|
raise AssertionError(f"no work {work_id} in fixture")
|
|
|
|
|
|
def _post_only(work_id):
|
|
"""Normalized post WITHOUT media extraction — the write_post_record tests
|
|
don't need media, and extracting an ugoira would hit the API seam."""
|
|
page = json.loads(_FIXTURE.read_text())
|
|
for work in page["illusts"]:
|
|
if work["id"] == work_id:
|
|
return _client()._normalize(work)
|
|
raise AssertionError(f"no work {work_id} in fixture")
|
|
|
|
|
|
def _downloader(tmp_path, session=None):
|
|
# validate=False: the stub payload is PNG bytes regardless of the target
|
|
# extension, so real validation would quarantine the .jpg cases. The
|
|
# validate/quarantine plumbing is BaseNativeDownloader's, covered by the
|
|
# Patreon downloader tests.
|
|
return PixivDownloader(
|
|
tmp_path, validate=False,
|
|
session=session if session is not None else _FakeSession(),
|
|
)
|
|
|
|
|
|
def test_download_post_writes_gallery_dl_layout(tmp_path):
|
|
post, media = _post_and_media(111)
|
|
dl = _downloader(tmp_path)
|
|
outcomes = dl.download_post(post, media, "artist-a")
|
|
|
|
assert [o.status for o in outcomes] == ["downloaded", "downloaded"]
|
|
flat = tmp_path / "artist-a" / "pixiv" / "pixiv"
|
|
p0 = flat / "111_Multi Page Adventure_00.png"
|
|
p1 = flat / "111_Multi Page Adventure_01.png"
|
|
assert p0.is_file()
|
|
assert p1.is_file()
|
|
# No per-post directory — pixiv's layout is flat (gallery-dl parity).
|
|
assert {p.parent for p in (p0, p1)} == {flat}
|
|
|
|
|
|
def test_download_post_writes_minimal_sidecar(tmp_path):
|
|
post, media = _post_and_media(222)
|
|
dl = _downloader(tmp_path)
|
|
outcomes = dl.download_post(post, media, "artist-a")
|
|
|
|
assert outcomes[0].status == "downloaded"
|
|
sidecar = outcomes[0].path.with_suffix(".json")
|
|
data = json.loads(sidecar.read_text())
|
|
# Post-first: image identity ONLY — the body lives in the post record.
|
|
assert data == {
|
|
"category": "pixiv",
|
|
"id": "222",
|
|
"source_url": media[0].url,
|
|
}
|
|
|
|
|
|
def test_download_post_skips_seen(tmp_path):
|
|
post, media = _post_and_media(222)
|
|
session = _FakeSession()
|
|
dl = _downloader(tmp_path, session=session)
|
|
outcomes = dl.download_post(post, media, "artist-a", is_seen=lambda m: True)
|
|
assert [o.status for o in outcomes] == ["skipped_seen"]
|
|
assert session.calls == []
|
|
|
|
|
|
def test_download_post_skips_on_disk(tmp_path):
|
|
post, media = _post_and_media(222)
|
|
flat = tmp_path / "artist-a" / "pixiv" / "pixiv"
|
|
flat.mkdir(parents=True)
|
|
existing = flat / "222_Single Piece_00.jpg"
|
|
existing.write_bytes(b"already here")
|
|
|
|
session = _FakeSession()
|
|
dl = _downloader(tmp_path, session=session)
|
|
outcomes = dl.download_post(post, media, "artist-a")
|
|
assert [o.status for o in outcomes] == ["skipped_disk"]
|
|
assert outcomes[0].path == existing
|
|
assert session.calls == []
|
|
assert existing.read_bytes() == b"already here"
|
|
|
|
|
|
def test_recapture_surfaces_on_disk_path_for_seen_media(tmp_path):
|
|
post, media = _post_and_media(222)
|
|
flat = tmp_path / "artist-a" / "pixiv" / "pixiv"
|
|
flat.mkdir(parents=True)
|
|
(flat / "222_Single Piece_00.jpg").write_bytes(b"kept")
|
|
|
|
dl = _downloader(tmp_path)
|
|
outcomes = dl.download_post(
|
|
post, media, "artist-a", is_seen=lambda m: True, recapture=True,
|
|
)
|
|
# On disk → surfaced with its path (relink channel); seen-but-missing
|
|
# stays skipped_seen (recovery's job, not recapture's).
|
|
assert outcomes[0].status == "skipped_disk"
|
|
assert outcomes[0].path is not None
|
|
|
|
|
|
def test_recapture_does_not_redownload_missing_seen_media(tmp_path):
|
|
post, media = _post_and_media(222)
|
|
session = _FakeSession()
|
|
dl = _downloader(tmp_path, session=session)
|
|
outcomes = dl.download_post(
|
|
post, media, "artist-a", is_seen=lambda m: True, recapture=True,
|
|
)
|
|
assert [o.status for o in outcomes] == ["skipped_seen"]
|
|
assert session.calls == []
|
|
|
|
|
|
def test_download_post_honours_should_stop(tmp_path):
|
|
post, media = _post_and_media(111)
|
|
dl = _downloader(tmp_path)
|
|
outcomes = dl.download_post(post, media, "artist-a", should_stop=lambda: True)
|
|
assert outcomes == []
|
|
|
|
|
|
def test_filename_matches_gallery_dl_linux_restrict(tmp_path):
|
|
# gallery-dl on Linux replaces ONLY "/" and deletes control chars — the
|
|
# Windows-forbidden set (<>:"|?*) stays RAW in the on-disk name. Matching
|
|
# that byte-for-byte is what lets the tier-2 disk-skip recognize
|
|
# pre-cutover files; a stricter sanitizer would re-download them.
|
|
post, _ = _post_and_media(222)
|
|
weird = MediaItem(
|
|
url="https://i.pximg.net/img-original/img/2026/06/18/12/30/00/222_p0.jpg",
|
|
filename='222_What? A "Title"/Slash\x0900.jpg', # ? " stay, / → _, \t dropped
|
|
kind="image",
|
|
filehash=None,
|
|
post_id="222",
|
|
media_id="p0",
|
|
)
|
|
dl = _downloader(tmp_path)
|
|
outcomes = dl.download_post(post, [weird], "artist-a")
|
|
assert outcomes[0].status == "downloaded"
|
|
assert outcomes[0].path.name == '222_What? A "Title"_Slash00.jpg'
|
|
|
|
|
|
def test_gdl_clean_filename_parity():
|
|
from backend.app.services.pixiv_downloader import gdl_clean_filename
|
|
# Only "/" is replaced; the Windows-forbidden set is untouched.
|
|
assert gdl_clean_filename('a<>:"|?*b.png') == 'a<>:"|?*b.png'
|
|
assert gdl_clean_filename("a/b/c.png") == "a_b_c.png"
|
|
# Control chars (newline, tab, DEL) are deleted, not replaced.
|
|
assert gdl_clean_filename("a\nb\tc\x7fd.png") == "abcd.png"
|
|
# Trailing dots/spaces are NOT stripped on Linux.
|
|
assert gdl_clean_filename("title . .png") == "title . .png"
|
|
|
|
|
|
def test_own_session_carries_app_referer(tmp_path):
|
|
# Built session (no injection) must carry the app profile — i.pximg.net
|
|
# 403s GETs without the app-api Referer.
|
|
dl = PixivDownloader(tmp_path)
|
|
assert dl.session.headers["Referer"] == "https://app-api.pixiv.net/"
|
|
assert dl.session.headers["App-OS"] == "ios"
|
|
|
|
|
|
def test_write_post_record_enriched(tmp_path):
|
|
post = _post_only(111)
|
|
dl = _downloader(tmp_path)
|
|
outcome = dl.write_post_record(post, "artist-a")
|
|
|
|
assert outcome.path == (
|
|
tmp_path / "artist-a" / "pixiv" / "pixiv" / "_post_111.json"
|
|
)
|
|
assert outcome.title == "Multi Page Adventure"
|
|
assert outcome.post_type == "illust"
|
|
assert outcome.body_chars == len(post["attributes"]["content"])
|
|
|
|
data = json.loads(outcome.path.read_text())
|
|
assert data["category"] == "pixiv"
|
|
assert data["id"] == "111"
|
|
assert data["published_at"] == "2026-06-20T18:00:00+09:00"
|
|
assert data["rating"] == "General"
|
|
assert data["page_count"] == 2
|
|
assert data["total_bookmarks"] == 250
|
|
assert data["total_view"] == 1000
|
|
assert data["illust_ai_type"] == 1
|
|
assert data["series"] == {"id": 4242, "title": "Adventure Series"}
|
|
assert data["tags"] == [
|
|
{"name": "オリジナル", "translated_name": "original"},
|
|
{"name": "女の子", "translated_name": "girl"},
|
|
]
|
|
assert data["user"] == {"id": 99, "account": "exartist", "name": "Example Artist"}
|
|
# JP tags stay human-readable on disk (ensure_ascii=False).
|
|
assert "オリジナル" in outcome.path.read_text()
|
|
|
|
|
|
def test_write_post_record_rating_r18(tmp_path):
|
|
post = _post_only(222)
|
|
dl = _downloader(tmp_path)
|
|
outcome = dl.write_post_record(post, "artist-a")
|
|
data = json.loads(outcome.path.read_text())
|
|
assert data["rating"] == "R-18"
|
|
assert data["is_bookmarked"] is True
|
|
|
|
|
|
def test_write_post_record_includes_ugoira_frames(tmp_path):
|
|
post = _post_only(333)
|
|
frames = [{"file": "000000.jpg", "delay": 90}]
|
|
post["_work"]["_ugoira_frames"] = frames
|
|
dl = _downloader(tmp_path)
|
|
outcome = dl.write_post_record(post, "artist-a")
|
|
data = json.loads(outcome.path.read_text())
|
|
assert data["type"] == "ugoira"
|
|
assert data["ugoira_frames"] == frames
|
|
|
|
|
|
def test_write_post_record_fetches_ugoira_frames_when_absent(tmp_path):
|
|
# The core writes the post record BEFORE extract_media, so frames aren't
|
|
# memoized yet — the injected fetcher must populate them so the record keeps
|
|
# the timings (regression: they were silently always empty).
|
|
post = _post_only(333)
|
|
assert "_ugoira_frames" not in post["_work"]
|
|
frames = [{"file": "000000.jpg", "delay": 120}]
|
|
calls = []
|
|
|
|
def fetcher(p):
|
|
calls.append(p)
|
|
p["_work"]["_ugoira_frames"] = frames
|
|
|
|
dl = PixivDownloader(
|
|
tmp_path, validate=False, session=_FakeSession(),
|
|
ugoira_frames_fetcher=fetcher,
|
|
)
|
|
outcome = dl.write_post_record(post, "artist-a")
|
|
data = json.loads(outcome.path.read_text())
|
|
assert data["ugoira_frames"] == frames
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_write_post_record_non_ugoira_does_not_call_fetcher(tmp_path):
|
|
post = _post_only(111) # a plain multi-page illust
|
|
calls = []
|
|
dl = PixivDownloader(
|
|
tmp_path, validate=False, session=_FakeSession(),
|
|
ugoira_frames_fetcher=lambda p: calls.append(p),
|
|
)
|
|
dl.write_post_record(post, "artist-a")
|
|
assert calls == []
|
|
|
|
|
|
def test_write_post_record_without_id(tmp_path):
|
|
dl = _downloader(tmp_path)
|
|
outcome = dl.write_post_record({"id": None, "attributes": {}}, "artist-a")
|
|
assert outcome.path is None
|
|
assert outcome.body_chars == 0
|