refactor: the native downloaders share one download loop, file record and seen-ledger key; a clean walk marks its credential verified
CI and images / lint (push) Failing after 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 23s
CI and images / frontend-build (push) Successful in 26s
CI and images / backend-lint-and-test (push) Successful in 53s
CI and images / integration (push) Successful in 2m50s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
CI and images / lint (push) Failing after 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 23s
CI and images / frontend-build (push) Successful in 26s
CI and images / backend-lint-and-test (push) Successful in 53s
CI and images / integration (push) Successful in 2m50s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
Patreon, SubscribeStar and Discord each carried their own copy of the same per-post download loop, file record and seen-ledger key, and every copy was another place a fix could be missed. - BaseNativeDownloader runs one post's files: the seen and on-disk skips, the mid-post time-box, pacing, validation and the per-file sidecar. Each platform overrides only where its layout differs: Discord's channel folder, file names and message-id sidecar, and Patreon's Mux video through yt-dlp. - One MediaItem and one ledger_key in native_ingest_common replace the three per-client copies. Keys are unchanged, except that a SubscribeStar file with no gallery id is now keyed by its filename instead of an empty id. - DownloadResult, ErrorType and the run-stats and tier-gated helpers move from gallery_dl.py, which now serves only HentaiFoundry, to download_result.py. The unused delegator methods on GalleryDLService go. - The download lock's platform list is the native-ingester list, not a copy. - A walk that used a credential and finished with no error type stamps that credential's last-verified time, so the credentials page shows when the account last worked. A tier-limited walk does not count: an expired session hides paid posts the same way. - The extension no longer clears a retired pixiv token on startup. - Two comments that still described every download as gallery-dl now name both backends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,8 +21,9 @@ from backend.app.models import (
|
||||
PatreonSeenMedia,
|
||||
Source,
|
||||
)
|
||||
from backend.app.services.gallery_dl import ErrorType
|
||||
from backend.app.services.download_result import ErrorType
|
||||
from backend.app.services.ingest_core import _CANARY_MIN_SAMPLE
|
||||
from backend.app.services.native_ingest_common import ledger_key
|
||||
from backend.app.services.patreon_client import (
|
||||
MediaItem,
|
||||
PatreonAPIError,
|
||||
@@ -30,7 +31,7 @@ from backend.app.services.patreon_client import (
|
||||
PatreonDriftError,
|
||||
)
|
||||
from backend.app.services.patreon_downloader import MediaOutcome, PostRecordOutcome
|
||||
from backend.app.services.patreon_ingester import PatreonIngester, _ledger_key
|
||||
from backend.app.services.patreon_ingester import PatreonIngester
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@@ -141,15 +142,15 @@ class _FakeDownloader:
|
||||
# disk is left alone (skipped_seen, never refetched).
|
||||
if seen and not recapture:
|
||||
outcomes.append(MediaOutcome(media=m, status="skipped_seen", path=None, error=None))
|
||||
elif _ledger_key(m) in self.on_disk:
|
||||
elif ledger_key(m) in self.on_disk:
|
||||
p = self.tmp_path / f"{m.post_id}_{m.filename}"
|
||||
outcomes.append(MediaOutcome(media=m, status="skipped_disk", path=p, error=None))
|
||||
elif seen:
|
||||
outcomes.append(MediaOutcome(media=m, status="skipped_seen", path=None, error=None))
|
||||
elif _ledger_key(m) in self.error:
|
||||
elif ledger_key(m) in self.error:
|
||||
self.download_calls += 1
|
||||
outcomes.append(MediaOutcome(media=m, status="error", path=None, error="boom"))
|
||||
elif _ledger_key(m) in self.quarantine:
|
||||
elif ledger_key(m) in self.quarantine:
|
||||
self.download_calls += 1
|
||||
q = self.tmp_path / "_quarantine" / f"{m.post_id}_{m.filename}"
|
||||
outcomes.append(MediaOutcome(media=m, status="quarantined", path=q, error="bad image"))
|
||||
@@ -255,7 +256,7 @@ async def test_quarantined_media_surfaced_in_result(source_id, sync_engine, tmp_
|
||||
NOT written or marked seen."""
|
||||
m1, m2 = _media("p1", 1), _media("p1", 2)
|
||||
client = _FakeClient([(None, [("p1", [m1, m2])])])
|
||||
downloader = _FakeDownloader(tmp_path, quarantine={_ledger_key(m2)})
|
||||
downloader = _FakeDownloader(tmp_path, quarantine={ledger_key(m2)})
|
||||
ing = _ingester(sync_engine, tmp_path, client, downloader)
|
||||
|
||||
result = ing.run(
|
||||
@@ -280,7 +281,7 @@ async def test_tick_skips_seen_via_ledger(source_id, sync_engine, tmp_path):
|
||||
# Pre-seed the ledger with m1's key.
|
||||
factory = sessionmaker(sync_engine, expire_on_commit=False)
|
||||
with factory() as s:
|
||||
s.add(PatreonSeenMedia(source_id=source_id, filehash=_ledger_key(m1), post_id="p1"))
|
||||
s.add(PatreonSeenMedia(source_id=source_id, filehash=ledger_key(m1), post_id="p1"))
|
||||
s.commit()
|
||||
|
||||
client = _FakeClient([(None, [("p1", [m1])])])
|
||||
@@ -302,7 +303,7 @@ async def test_tick_early_out_after_threshold(source_id, sync_engine, tmp_path):
|
||||
factory = sessionmaker(sync_engine, expire_on_commit=False)
|
||||
with factory() as s:
|
||||
for m in seen_media:
|
||||
s.add(PatreonSeenMedia(source_id=source_id, filehash=_ledger_key(m), post_id=m.post_id))
|
||||
s.add(PatreonSeenMedia(source_id=source_id, filehash=ledger_key(m), post_id=m.post_id))
|
||||
s.commit()
|
||||
|
||||
pages = [(None, [(m.post_id, [m]) for m in seen_media])]
|
||||
@@ -348,7 +349,7 @@ def _seed_seen_media(sync_engine, source_id, media):
|
||||
factory = sessionmaker(sync_engine, expire_on_commit=False)
|
||||
with factory() as s:
|
||||
for m in media:
|
||||
s.add(PatreonSeenMedia(source_id=source_id, filehash=_ledger_key(m), post_id=m.post_id))
|
||||
s.add(PatreonSeenMedia(source_id=source_id, filehash=ledger_key(m), post_id=m.post_id))
|
||||
s.commit()
|
||||
|
||||
|
||||
@@ -615,7 +616,7 @@ async def test_recovery_bypasses_seen_ledger(source_id, sync_engine, tmp_path):
|
||||
m1 = _media("p1", 1)
|
||||
factory = sessionmaker(sync_engine, expire_on_commit=False)
|
||||
with factory() as s:
|
||||
s.add(PatreonSeenMedia(source_id=source_id, filehash=_ledger_key(m1), post_id="p1"))
|
||||
s.add(PatreonSeenMedia(source_id=source_id, filehash=ledger_key(m1), post_id="p1"))
|
||||
s.commit()
|
||||
|
||||
client = _FakeClient([(None, [("p1", [m1])])])
|
||||
@@ -638,7 +639,7 @@ async def test_recovery_tier2_disk_still_skips(source_id, sync_engine, tmp_path)
|
||||
client = _FakeClient([(None, [("p1", [m1])])])
|
||||
# File still on disk (a kept image) → tier-2 spares it even under recovery.
|
||||
_seed_record(sync_engine, tmp_path / "p1_1.jpg")
|
||||
downloader = _FakeDownloader(tmp_path, on_disk={_ledger_key(m1)})
|
||||
downloader = _FakeDownloader(tmp_path, on_disk={ledger_key(m1)})
|
||||
ing = _ingester(sync_engine, tmp_path, client, downloader)
|
||||
|
||||
result = ing.run(
|
||||
@@ -665,7 +666,7 @@ async def test_backfill_recaptures_body_for_already_downloaded_post(
|
||||
itself, and a normal backfill is the backfill."""
|
||||
m1 = _media("p1", 1)
|
||||
client = _FakeClient([(None, [("p1", [m1])])])
|
||||
downloader = _FakeDownloader(tmp_path, on_disk={_ledger_key(m1)})
|
||||
downloader = _FakeDownloader(tmp_path, on_disk={ledger_key(m1)})
|
||||
ing = _ingester(sync_engine, tmp_path, client, downloader)
|
||||
|
||||
result = ing.run(
|
||||
@@ -704,13 +705,13 @@ async def test_backfill_skips_already_captured_post_but_recapture_forces_it(
|
||||
re-downloading the on-disk media."""
|
||||
m1 = _media("p1", 1)
|
||||
# Pre-seed BOTH the media key and the synthetic post key as already seen.
|
||||
_seed_seen(sync_engine, source_id, _ledger_key(m1), post_id="p1")
|
||||
_seed_seen(sync_engine, source_id, ledger_key(m1), post_id="p1")
|
||||
_seed_seen(sync_engine, source_id, "post:p1", post_id="p1")
|
||||
_seed_record(sync_engine, tmp_path / "p1_1.jpg")
|
||||
|
||||
# 1) Plain backfill: post key is seen → gate skips body recapture.
|
||||
client = _FakeClient([(None, [("p1", [m1])])])
|
||||
downloader = _FakeDownloader(tmp_path, on_disk={_ledger_key(m1)})
|
||||
downloader = _FakeDownloader(tmp_path, on_disk={ledger_key(m1)})
|
||||
ing = _ingester(sync_engine, tmp_path, client, downloader)
|
||||
res_backfill = ing.run(
|
||||
source_id=source_id, campaign_id="c1", artist_slug="ingest",
|
||||
@@ -722,7 +723,7 @@ async def test_backfill_skips_already_captured_post_but_recapture_forces_it(
|
||||
# 2) Recapture: gate bypassed → body recaptured; media NOT re-downloaded;
|
||||
# the on-disk file is surfaced for source_filehash relink.
|
||||
client2 = _FakeClient([(None, [("p1", [m1])])])
|
||||
downloader2 = _FakeDownloader(tmp_path, on_disk={_ledger_key(m1)})
|
||||
downloader2 = _FakeDownloader(tmp_path, on_disk={ledger_key(m1)})
|
||||
ing2 = _ingester(sync_engine, tmp_path, client2, downloader2)
|
||||
res_recap = ing2.run(
|
||||
source_id=source_id, campaign_id="c1", artist_slug="ingest",
|
||||
@@ -892,7 +893,7 @@ async def test_recapture_does_not_refetch_seen_media_missing_from_disk(
|
||||
"""Recapture must NOT re-download a seen item that's absent from disk (that's
|
||||
recovery's job) — it returns skipped_seen and contributes no relink pair."""
|
||||
m1 = _media("p1", 1)
|
||||
_seed_seen(sync_engine, source_id, _ledger_key(m1), post_id="p1")
|
||||
_seed_seen(sync_engine, source_id, ledger_key(m1), post_id="p1")
|
||||
client = _FakeClient([(None, [("p1", [m1])])])
|
||||
# NOT in on_disk → seen + missing.
|
||||
downloader = _FakeDownloader(tmp_path)
|
||||
@@ -952,7 +953,7 @@ async def test_a_run_that_dies_before_import_leaves_its_media_unmarked(
|
||||
|
||||
# The next walk finds the file on disk with no record, and imports it.
|
||||
ing2 = _ingester(sync_engine, tmp_path, _FakeClient([(None, [("p1", [m1])])]),
|
||||
_FakeDownloader(tmp_path, on_disk={_ledger_key(m1)}))
|
||||
_FakeDownloader(tmp_path, on_disk={ledger_key(m1)}))
|
||||
result = ing2.run(source_id=source_id, campaign_id="c1", artist_slug="ingest",
|
||||
url="https://patreon.com/ingest", mode="tick")
|
||||
assert result.written_paths == [str(tmp_path / "p1_1.jpg")]
|
||||
@@ -971,9 +972,9 @@ async def test_recapture_imports_a_seen_file_nothing_imported(
|
||||
a walk that looks past it — recapture — reaches them."""
|
||||
m1, m2 = _media("p1", 1), _media("p1", 2)
|
||||
for m in (m1, m2):
|
||||
_seed_seen(sync_engine, source_id, _ledger_key(m), post_id="p1")
|
||||
_seed_seen(sync_engine, source_id, ledger_key(m), post_id="p1")
|
||||
_seed_record(sync_engine, tmp_path / "p1_2.jpg") # m2 made it in; m1 did not
|
||||
downloader = _FakeDownloader(tmp_path, on_disk={_ledger_key(m1), _ledger_key(m2)})
|
||||
downloader = _FakeDownloader(tmp_path, on_disk={ledger_key(m1), ledger_key(m2)})
|
||||
ing = _ingester(sync_engine, tmp_path, _FakeClient([(None, [("p1", [m1, m2])])]),
|
||||
downloader)
|
||||
result = ing.run(source_id=source_id, campaign_id="c1", artist_slug="ingest",
|
||||
@@ -1002,7 +1003,7 @@ async def test_files_phase_3_leaves_unimported_stay_unmarked(
|
||||
assert _count_ledger(sync_engine, source_id) == 3 # m1 and both post records
|
||||
|
||||
ing2 = _ingester(sync_engine, tmp_path, _FakeClient(pages),
|
||||
_FakeDownloader(tmp_path, on_disk={_ledger_key(m1), _ledger_key(m2)}))
|
||||
_FakeDownloader(tmp_path, on_disk={ledger_key(m1), ledger_key(m2)}))
|
||||
again = ing2.run(source_id=source_id, campaign_id="c1", artist_slug="ingest",
|
||||
url="https://patreon.com/ingest", mode="backfill")
|
||||
assert again.written_paths == [str(tmp_path / "p2_1.jpg")]
|
||||
@@ -1041,7 +1042,7 @@ async def test_repeated_failures_dead_letter_then_skip(source_id, sync_engine, t
|
||||
ing = _ingester(
|
||||
sync_engine, tmp_path,
|
||||
_FakeClient([(None, [("p1", [m1])])]),
|
||||
_FakeDownloader(tmp_path, error={_ledger_key(m1)}),
|
||||
_FakeDownloader(tmp_path, error={ledger_key(m1)}),
|
||||
)
|
||||
result = ing.run(
|
||||
source_id=source_id, campaign_id="c1", artist_slug="ingest",
|
||||
@@ -1070,7 +1071,7 @@ async def test_recovery_reattempts_dead_lettered_and_clears_on_success(
|
||||
from backend.app.services.patreon_ingester import DEAD_LETTER_THRESHOLD
|
||||
|
||||
m1 = _media("p1", 1)
|
||||
_seed_failed(sync_engine, source_id, _ledger_key(m1), DEAD_LETTER_THRESHOLD)
|
||||
_seed_failed(sync_engine, source_id, ledger_key(m1), DEAD_LETTER_THRESHOLD)
|
||||
|
||||
downloader = _FakeDownloader(tmp_path) # succeeds
|
||||
ing = _ingester(sync_engine, tmp_path, _FakeClient([(None, [("p1", [m1])])]), downloader)
|
||||
@@ -1090,7 +1091,7 @@ async def test_clean_download_clears_below_threshold_failure(
|
||||
"""A media with a sub-threshold failure that now downloads cleanly (tick) has
|
||||
its dead-letter row cleared."""
|
||||
m1 = _media("p1", 1)
|
||||
_seed_failed(sync_engine, source_id, _ledger_key(m1), 1) # not yet dead
|
||||
_seed_failed(sync_engine, source_id, ledger_key(m1), 1) # not yet dead
|
||||
|
||||
downloader = _FakeDownloader(tmp_path)
|
||||
ing = _ingester(sync_engine, tmp_path, _FakeClient([(None, [("p1", [m1])])]), downloader)
|
||||
@@ -1113,8 +1114,8 @@ async def test_mark_seen_is_idempotent(source_id, sync_engine, tmp_path):
|
||||
ing = _ingester(sync_engine, tmp_path, client, downloader)
|
||||
|
||||
# Same media downloaded twice across two runs — the second marks seen again.
|
||||
ing._mark_seen(source_id, [(_ledger_key(m1), "p1")])
|
||||
ing._mark_seen(source_id, [(_ledger_key(m1), "p1")])
|
||||
ing._mark_seen(source_id, [(ledger_key(m1), "p1")])
|
||||
ing._mark_seen(source_id, [(ledger_key(m1), "p1")])
|
||||
assert _count_ledger(sync_engine, source_id) == 1
|
||||
|
||||
|
||||
@@ -1180,7 +1181,7 @@ def test_ledger_key_video_has_no_filehash():
|
||||
url="https://stream.mux.com/abc.m3u8", filename="clip.mp4",
|
||||
kind="postfile", filehash=None, post_id="p9",
|
||||
)
|
||||
assert _ledger_key(video) == "p9:clip.mp4"
|
||||
assert ledger_key(video) == "p9:clip.mp4"
|
||||
|
||||
|
||||
# --- verify_patreon_credential (the verify counterpart, plan #697) ---------
|
||||
@@ -1350,7 +1351,7 @@ def _seed_all_seen(sync_engine, source_id, media):
|
||||
the tests that use it is invisible at the point of use.
|
||||
"""
|
||||
for m in media:
|
||||
_seed_seen(sync_engine, source_id, _ledger_key(m), post_id=m.post_id)
|
||||
_seed_seen(sync_engine, source_id, ledger_key(m), post_id=m.post_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user