fix(pixiv): match gallery-dl's exact on-disk filename to avoid re-download at cutover
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m35s

The native downloader used the Windows-safe sanitize_segment, but gallery-dl on
Linux (path-restrict auto→'/', path-remove default control chars, path-strip
auto→'') replaces ONLY '/' and deletes control chars — the Windows-forbidden set
(<>:"|?*) and trailing dots/spaces stay RAW in on-disk titles. Any pixiv title
with those chars would therefore miss the tier-2 disk-skip and re-download the
whole work at cutover (seen-ledger starts empty). Replace sanitize_segment with
gdl_clean_filename, a byte-exact mirror of gallery-dl 1.32.5 build_filename
(verified against path.py). Directory + template already matched; this closes the
last parity gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-03 13:49:04 -04:00
parent a7f715ec43
commit 544e30bfb9
2 changed files with 45 additions and 7 deletions
+27 -4
View File
@@ -34,6 +34,7 @@ from __future__ import annotations
import json import json
import logging import logging
import re
import time import time
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
@@ -44,12 +45,32 @@ from .native_ingest_common import (
BaseNativeDownloader, BaseNativeDownloader,
MediaOutcome, MediaOutcome,
PostRecordOutcome, PostRecordOutcome,
sanitize_segment,
) )
from .pixiv_client import PIXIV_APP_HEADERS, rating_label from .pixiv_client import PIXIV_APP_HEADERS, rating_label
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Control chars (0x000x1f + 0x7f DEL) — gallery-dl's default `path-remove`.
_GDL_PATH_REMOVE_RE = re.compile(r"[\x00-\x1f\x7f]")
def gdl_clean_filename(name: str) -> str:
"""Reproduce gallery-dl's on-disk filename EXACTLY as it wrote it on this
Linux host, so the tier-2 disk-skip recognizes pre-cutover files instead of
re-downloading them.
gallery-dl's PathFormat.build_filename is `clean_path(clean_segment(name))`.
On Linux (verified against gallery-dl 1.32.5 path.py) the defaults resolve to:
- path-restrict "auto""/" → clean_segment replaces ONLY "/""_"
- path-remove "\\x00-\\x1f\\x7f" → clean_path DELETES control chars
- path-strip "auto""" → NO trailing dot/space stripping
Crucially it does NOT touch the Windows-forbidden set (<>:"|?*) — those stay
raw in titles on disk. A stricter sanitizer here would rename any such title,
miss the on-disk match, and re-pull the whole work. Order mirrors gallery-dl
(segment inner, path outer); for these disjoint char sets it's commutative.
"""
return _GDL_PATH_REMOVE_RE.sub("", name.replace("/", "_"))
# Enrichment keys copied verbatim from the app-API work dict into the post # Enrichment keys copied verbatim from the app-API work dict into the post
# record (they're already JSON scalars/objects). Everything lands in # record (they're already JSON scalars/objects). Everything lands in
# Post.raw_metadata via the importer, so the archive keeps pixiv's stats and # Post.raw_metadata via the importer, so the archive keeps pixiv's stats and
@@ -150,9 +171,11 @@ class PixivDownloader(BaseNativeDownloader):
if seen and not recapture: if seen and not recapture:
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None) return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
# The client's filename already carries the {id}_{title50}_{NN} shape; # The client's filename already carries the {id}_{title50}_{NN} shape
# only path-restrict sanitization happens here. # (raw title, gallery-dl-template order); clean it to the byte-exact
media_path = flat_dir / sanitize_segment(media.filename) # name gallery-dl wrote on disk so tier-2 disk-skip matches (else a
# re-download of the whole work). See gdl_clean_filename.
media_path = flat_dir / gdl_clean_filename(media.filename)
if media_path.exists(): # tier-2: already on disk if media_path.exists(): # tier-2: already on disk
return MediaOutcome( return MediaOutcome(
+18 -3
View File
@@ -178,11 +178,15 @@ def test_download_post_honours_should_stop(tmp_path):
assert outcomes == [] assert outcomes == []
def test_filename_is_sanitized(tmp_path): 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) post, _ = _post_and_media(222)
weird = MediaItem( weird = MediaItem(
url="https://i.pximg.net/img-original/img/2026/06/18/12/30/00/222_p0.jpg", url="https://i.pximg.net/img-original/img/2026/06/18/12/30/00/222_p0.jpg",
filename='222_What? A "Title"/Slash_00.jpg', filename='222_What? A "Title"/Slash\x0900.jpg', # ? " stay, / → _, \t dropped
kind="image", kind="image",
filehash=None, filehash=None,
post_id="222", post_id="222",
@@ -191,7 +195,18 @@ def test_filename_is_sanitized(tmp_path):
dl = _downloader(tmp_path) dl = _downloader(tmp_path)
outcomes = dl.download_post(post, [weird], "artist-a") outcomes = dl.download_post(post, [weird], "artist-a")
assert outcomes[0].status == "downloaded" assert outcomes[0].status == "downloaded"
assert outcomes[0].path.name == "222_What_ A _Title__Slash_00.jpg" 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): def test_own_session_carries_app_referer(tmp_path):