Release: dev → main (first public release) #258
@@ -423,6 +423,13 @@ class DownloadService:
|
|||||||
|
|
||||||
await loop.run_in_executor(None, _upsert)
|
await loop.run_in_executor(None, _upsert)
|
||||||
|
|
||||||
|
# Only now is it safe to call this walk's media seen: every file above
|
||||||
|
# has been through the importer. Had the run died before here they stay
|
||||||
|
# unmarked, and the next walk imports them from disk (ingest_core).
|
||||||
|
mark_seen = getattr(dl_result, "mark_seen_after_import", None)
|
||||||
|
if mark_seen is not None:
|
||||||
|
await loop.run_in_executor(None, mark_seen)
|
||||||
|
|
||||||
# #830 recapture: backfill source_filehash on EXISTING on-disk images so
|
# #830 recapture: backfill source_filehash on EXISTING on-disk images so
|
||||||
# their post-body inline <img src=CDN> remaps to the local copy. A
|
# their post-body inline <img src=CDN> remaps to the local copy. A
|
||||||
# SEPARATE non-deleting channel (NOT the import list — that would unlink
|
# SEPARATE non-deleting channel (NOT the import list — that would unlink
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -230,6 +231,13 @@ class DownloadResult:
|
|||||||
# the platform cooldown matches the hint instead of a flat default. None when
|
# the platform cooldown matches the hint instead of a flat default. None when
|
||||||
# unknown (no header, or not a rate-limit failure).
|
# unknown (no header, or not a rate-limit failure).
|
||||||
retry_after_seconds: float | None = None
|
retry_after_seconds: float | None = None
|
||||||
|
# Native ingester only: marks this walk's fetched media seen in its ledger.
|
||||||
|
# Phase 3 calls it AFTER the import loop, never before — a file marked seen
|
||||||
|
# but not yet imported is invisible to every later walk, so a run killed in
|
||||||
|
# between orphaned it for good (TamadaHeijun's 12PCG post lost 7 of 13
|
||||||
|
# images to a stranded run, 2026-09-24). Unmarked, the next walk finds the
|
||||||
|
# file on disk with no ImageRecord and imports it. None on gallery-dl.
|
||||||
|
mark_seen_after_import: Callable[[], None] | None = None
|
||||||
|
|
||||||
|
|
||||||
def extract_errors_warnings(stderr: str) -> str:
|
def extract_errors_warnings(stderr: str) -> str:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from datetime import UTC, datetime, timedelta
|
|||||||
from sqlalchemy import delete, func, select, text
|
from sqlalchemy import delete, func, select, text
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
|
from ..models import ImageRecord
|
||||||
from .gallery_dl import (
|
from .gallery_dl import (
|
||||||
DownloadResult,
|
DownloadResult,
|
||||||
ErrorType,
|
ErrorType,
|
||||||
@@ -261,6 +262,9 @@ class Ingester:
|
|||||||
# source_filehash and (b) link the on-disk image to its Post (#1288) —
|
# source_filehash and (b) link the on-disk image to its Post (#1288) —
|
||||||
# WITHOUT re-downloading or unlinking the file. Empty outside recapture.
|
# WITHOUT re-downloading or unlinking the file. Empty outside recapture.
|
||||||
relink: list[tuple[str, str, str]] = []
|
relink: list[tuple[str, str, str]] = []
|
||||||
|
# Media handed to phase 3 for import. Marked seen by phase 3 once the
|
||||||
|
# import has run (`mark_seen_after_import`), not here — see there.
|
||||||
|
fetched: list[tuple[str, str]] = []
|
||||||
downloaded = 0
|
downloaded = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
quarantined = 0
|
quarantined = 0
|
||||||
@@ -313,6 +317,7 @@ class Ingester:
|
|||||||
written_paths=written,
|
written_paths=written,
|
||||||
post_record_paths=list(post_records),
|
post_record_paths=list(post_records),
|
||||||
relink_source_paths=list(relink),
|
relink_source_paths=list(relink),
|
||||||
|
mark_seen_after_import=lambda: self._mark_seen(source_id, fetched),
|
||||||
stdout="\n".join(log_lines),
|
stdout="\n".join(log_lines),
|
||||||
stderr="",
|
stderr="",
|
||||||
return_code=return_code,
|
return_code=return_code,
|
||||||
@@ -512,6 +517,13 @@ class Ingester:
|
|||||||
recapture=recapture,
|
recapture=recapture,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# An on-disk file is only "done" if something imported it. One
|
||||||
|
# with no ImageRecord at its path was written by a run that died
|
||||||
|
# before phase 3 — it goes to import, not to the ledger.
|
||||||
|
imported_paths = self._recorded_paths([
|
||||||
|
str(o.path) for o in outcomes
|
||||||
|
if o.status == "skipped_disk" and o.path is not None
|
||||||
|
])
|
||||||
to_mark: list[tuple[str, str]] = []
|
to_mark: list[tuple[str, str]] = []
|
||||||
to_clear: list[str] = [] # recovered → drop any dead-letter row
|
to_clear: list[str] = [] # recovered → drop any dead-letter row
|
||||||
to_fail: list[tuple[str, str, str]] = [] # (key, post_id, error)
|
to_fail: list[tuple[str, str, str]] = [] # (key, post_id, error)
|
||||||
@@ -523,11 +535,29 @@ class Ingester:
|
|||||||
downloaded += 1
|
downloaded += 1
|
||||||
if outcome.path is not None:
|
if outcome.path is not None:
|
||||||
written.append(str(outcome.path))
|
written.append(str(outcome.path))
|
||||||
to_mark.append((key, media_item.post_id))
|
fetched.append((key, media_item.post_id))
|
||||||
to_clear.append(key)
|
to_clear.append(key)
|
||||||
consecutive_seen = 0
|
consecutive_seen = 0
|
||||||
|
elif (
|
||||||
|
outcome.status == "skipped_disk"
|
||||||
|
and outcome.path is not None
|
||||||
|
and str(outcome.path) not in imported_paths
|
||||||
|
):
|
||||||
|
# On disk, never imported: a prior run wrote it and died
|
||||||
|
# before phase 3. Import it now. Safe to feed to
|
||||||
|
# attach_in_place because no record owns this path —
|
||||||
|
# the unlink below is about a file that IS the record.
|
||||||
|
written.append(str(outcome.path))
|
||||||
|
fetched.append((key, media_item.post_id))
|
||||||
|
to_clear.append(key)
|
||||||
|
skipped_count += 1
|
||||||
|
consecutive_seen += 1
|
||||||
|
log_lines.append(
|
||||||
|
f" post {media_item.post_id} — on disk but never "
|
||||||
|
f"imported: {outcome.path.name}"
|
||||||
|
)
|
||||||
elif outcome.status == "skipped_disk":
|
elif outcome.status == "skipped_disk":
|
||||||
# Already on disk (a prior run). Reconcile the ledger so a
|
# Already on disk and imported. Reconcile the ledger so a
|
||||||
# later tick skips it at tier-1 without a disk stat, but
|
# later tick skips it at tier-1 without a disk stat, but
|
||||||
# do NOT re-feed it to phase 3 — attach_in_place would see
|
# do NOT re-feed it to phase 3 — attach_in_place would see
|
||||||
# the duplicate sha256 and unlink the on-disk copy.
|
# the duplicate sha256 and unlink the on-disk copy.
|
||||||
@@ -887,6 +917,16 @@ class Ingester:
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
def _recorded_paths(self, paths: list[str]) -> set[str]:
|
||||||
|
"""Which of `paths` an ImageRecord already points at."""
|
||||||
|
if not paths:
|
||||||
|
return set()
|
||||||
|
with self.session_factory() as session:
|
||||||
|
rows = session.execute(
|
||||||
|
select(ImageRecord.path).where(ImageRecord.path.in_(paths))
|
||||||
|
).scalars().all()
|
||||||
|
return set(rows)
|
||||||
|
|
||||||
def _mark_seen(self, source_id: int, items: list[tuple[str, str]]) -> None:
|
def _mark_seen(self, source_id: int, items: list[tuple[str, str]]) -> None:
|
||||||
"""Idempotent upsert of (filehash, post_id) seen-ledger rows for a page.
|
"""Idempotent upsert of (filehash, post_id) seen-ledger rows for a page.
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,9 @@
|
|||||||
>
|
>
|
||||||
<v-list-item-title>Recapture post text & links</v-list-item-title>
|
<v-list-item-title>Recapture post text & links</v-list-item-title>
|
||||||
<v-list-item-subtitle>
|
<v-list-item-subtitle>
|
||||||
Re-grab every post's body + external links and localize inline images
|
Re-grab every post's body + external links, localize inline images
|
||||||
already on disk — without re-downloading media
|
already on disk, and import any downloaded file that never reached the
|
||||||
|
library — without re-downloading media
|
||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-divider v-if="isNative && !running" />
|
<v-divider v-if="isNative && !running" />
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from backend.app.models import Artist, DownloadEvent, ImportSettings, Source
|
from backend.app.models import Artist, DownloadEvent, ImageRecord, ImportSettings, Source
|
||||||
from backend.app.services.credential_crypto import CredentialCrypto
|
from backend.app.services.credential_crypto import CredentialCrypto
|
||||||
from backend.app.services.credential_service import CredentialService
|
from backend.app.services.credential_service import CredentialService
|
||||||
from backend.app.services.thumbnailer import Thumbnailer
|
from backend.app.services.thumbnailer import Thumbnailer
|
||||||
@@ -152,6 +152,14 @@ async def test_download_source_attaches_written_files(
|
|||||||
files_downloaded=2,
|
files_downloaded=2,
|
||||||
stdout=f"{f1}\n{f2}\n",
|
stdout=f"{f1}\n{f2}\n",
|
||||||
)
|
)
|
||||||
|
# The ledger is marked only once the files are in: a run killed before
|
||||||
|
# import must leave them unmarked so the next walk imports them.
|
||||||
|
imported_when_marked = []
|
||||||
|
result.mark_seen_after_import = lambda: imported_when_marked.append(
|
||||||
|
db_sync.execute(
|
||||||
|
select(func.count(ImageRecord.id)).where(ImageRecord.path.in_([str(f1), str(f2)]))
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
fake_gdl = _fake_gdl_with_result(result)
|
fake_gdl = _fake_gdl_with_result(result)
|
||||||
|
|
||||||
sync_settings = db_sync.execute(
|
sync_settings = db_sync.execute(
|
||||||
@@ -182,6 +190,7 @@ async def test_download_source_attaches_written_files(
|
|||||||
assert ev.files_count == 2
|
assert ev.files_count == 2
|
||||||
assert ev.metadata_["import_summary"]["attached"] == 2
|
assert ev.metadata_["import_summary"]["attached"] == 2
|
||||||
assert ev.metadata_["run_stats"]["downloaded_count"] == 2
|
assert ev.metadata_["run_stats"]["downloaded_count"] == 2
|
||||||
|
assert imported_when_marked == [2]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ real CDN. The ledger is real (a sync sessionmaker bound to the test engine), so
|
|||||||
the tier-1 skip and the idempotent mark-seen run against actual rows.
|
the tier-1 skip and the idempotent mark-seen run against actual rows.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -15,6 +16,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from backend.app.models import (
|
from backend.app.models import (
|
||||||
Artist,
|
Artist,
|
||||||
|
ImageRecord,
|
||||||
PatreonFailedMedia,
|
PatreonFailedMedia,
|
||||||
PatreonSeenMedia,
|
PatreonSeenMedia,
|
||||||
Source,
|
Source,
|
||||||
@@ -237,6 +239,9 @@ async def test_tick_downloads_unseen_and_marks_seen(source_id, sync_engine, tmp_
|
|||||||
# plan #704: structured run_stats carry the real counts.
|
# plan #704: structured run_stats carry the real counts.
|
||||||
assert result.run_stats["downloaded_count"] == 2
|
assert result.run_stats["downloaded_count"] == 2
|
||||||
assert result.posts_processed == 1
|
assert result.posts_processed == 1
|
||||||
|
# The media wait for phase 3 to import them; only the post key is in yet.
|
||||||
|
assert _count_ledger(sync_engine, source_id) == 1
|
||||||
|
result.mark_seen_after_import()
|
||||||
# 2 media keys + 1 synthetic post key (body/links recaptured per post).
|
# 2 media keys + 1 synthetic post key (body/links recaptured per post).
|
||||||
assert _count_ledger(sync_engine, source_id) == 3
|
assert _count_ledger(sync_engine, source_id) == 3
|
||||||
# The post body + links are captured for media posts too (rides the walk).
|
# The post body + links are captured for media posts too (rides the walk).
|
||||||
@@ -263,6 +268,7 @@ async def test_quarantined_media_surfaced_in_result(source_id, sync_engine, tmp_
|
|||||||
assert result.run_stats["quarantined_count"] == 1
|
assert result.run_stats["quarantined_count"] == 1
|
||||||
assert result.run_stats["downloaded_count"] == 1
|
assert result.run_stats["downloaded_count"] == 1
|
||||||
assert len(result.written_paths) == 1 # quarantined NOT written
|
assert len(result.written_paths) == 1 # quarantined NOT written
|
||||||
|
result.mark_seen_after_import()
|
||||||
# Quarantined media is NOT marked seen (a fixed file may be re-fetched);
|
# Quarantined media is NOT marked seen (a fixed file may be re-fetched);
|
||||||
# m1 + the synthetic post key (body/links captured per post) = 2.
|
# m1 + the synthetic post key (body/links captured per post) = 2.
|
||||||
assert _count_ledger(sync_engine, source_id) == 2
|
assert _count_ledger(sync_engine, source_id) == 2
|
||||||
@@ -573,6 +579,7 @@ async def test_recovery_tier2_disk_still_skips(source_id, sync_engine, tmp_path)
|
|||||||
m1 = _media("p1", 1)
|
m1 = _media("p1", 1)
|
||||||
client = _FakeClient([(None, [("p1", [m1])])])
|
client = _FakeClient([(None, [("p1", [m1])])])
|
||||||
# File still on disk (a kept image) → tier-2 spares it even under recovery.
|
# 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)
|
ing = _ingester(sync_engine, tmp_path, client, downloader)
|
||||||
|
|
||||||
@@ -582,6 +589,7 @@ async def test_recovery_tier2_disk_still_skips(source_id, sync_engine, tmp_path)
|
|||||||
)
|
)
|
||||||
assert result.files_downloaded == 0
|
assert result.files_downloaded == 0
|
||||||
assert downloader.download_calls == 0
|
assert downloader.download_calls == 0
|
||||||
|
assert result.written_paths == []
|
||||||
# Disk-skip reconciles the media key + the synthetic post key (recovery
|
# Disk-skip reconciles the media key + the synthetic post key (recovery
|
||||||
# recaptures the body/links per post) = 2.
|
# recaptures the body/links per post) = 2.
|
||||||
assert _count_ledger(sync_engine, source_id) == 2
|
assert _count_ledger(sync_engine, source_id) == 2
|
||||||
@@ -609,6 +617,15 @@ async def test_backfill_recaptures_body_for_already_downloaded_post(
|
|||||||
assert downloader.post_records == 1
|
assert downloader.post_records == 1
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_record(sync_engine, path):
|
||||||
|
"""An ImageRecord at `path` — the file was imported, not just downloaded."""
|
||||||
|
factory = sessionmaker(sync_engine, expire_on_commit=False)
|
||||||
|
with factory() as s:
|
||||||
|
s.add(ImageRecord(path=str(path), sha256=hashlib.sha256(str(path).encode()).hexdigest(),
|
||||||
|
size_bytes=1, mime="image/jpeg", origin="downloaded"))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
def _seed_seen(sync_engine, source_id, key, post_id=None):
|
def _seed_seen(sync_engine, source_id, key, post_id=None):
|
||||||
factory = sessionmaker(sync_engine, expire_on_commit=False)
|
factory = sessionmaker(sync_engine, expire_on_commit=False)
|
||||||
with factory() as s:
|
with factory() as s:
|
||||||
@@ -629,6 +646,7 @@ async def test_backfill_skips_already_captured_post_but_recapture_forces_it(
|
|||||||
# Pre-seed BOTH the media key and the synthetic post key as already seen.
|
# 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_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.
|
# 1) Plain backfill: post key is seen → gate skips body recapture.
|
||||||
client = _FakeClient([(None, [("p1", [m1])])])
|
client = _FakeClient([(None, [("p1", [m1])])])
|
||||||
@@ -696,6 +714,7 @@ async def test_gated_post_skipped_entirely_no_media_no_record(
|
|||||||
assert downloader.download_calls == 1
|
assert downloader.download_calls == 1
|
||||||
assert len(result.post_record_paths) == 1 # only the open post
|
assert len(result.post_record_paths) == 1 # only the open post
|
||||||
assert downloader.post_records == 1
|
assert downloader.post_records == 1
|
||||||
|
result.mark_seen_after_import()
|
||||||
# The gated post left NO trace in the seen-ledger (no media key, no post key):
|
# The gated post left NO trace in the seen-ledger (no media key, no post key):
|
||||||
# only the open post's media key + synthetic post key are recorded.
|
# only the open post's media key + synthetic post key are recorded.
|
||||||
assert _count_ledger(sync_engine, source_id) == 2
|
assert _count_ledger(sync_engine, source_id) == 2
|
||||||
@@ -827,6 +846,57 @@ async def test_recapture_does_not_refetch_seen_media_missing_from_disk(
|
|||||||
assert result.relink_source_paths == []
|
assert result.relink_source_paths == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- a run that dies between download and import ---------------------------
|
||||||
|
# TamadaHeijun's 【12PCG】 post, 2026-09-24: 13 files on disk, 5 in the library.
|
||||||
|
# A run wrote 01–08, marked them seen, and was killed before phase 3 imported
|
||||||
|
# them; every later walk trusted the ledger and never looked again.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_run_that_dies_before_import_leaves_its_media_unmarked(
|
||||||
|
source_id, sync_engine, tmp_path,
|
||||||
|
):
|
||||||
|
m1 = _media("p1", 1)
|
||||||
|
ing = _ingester(sync_engine, tmp_path, _FakeClient([(None, [("p1", [m1])])]),
|
||||||
|
_FakeDownloader(tmp_path))
|
||||||
|
ing.run(source_id=source_id, campaign_id="c1", artist_slug="ingest",
|
||||||
|
url="https://patreon.com/ingest", mode="tick")
|
||||||
|
# Phase 3 never ran, so `mark_seen_after_import` never did: only the post key.
|
||||||
|
assert _count_ledger(sync_engine, source_id) == 1
|
||||||
|
|
||||||
|
# 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)}))
|
||||||
|
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")]
|
||||||
|
assert result.files_downloaded == 0 # not fetched again
|
||||||
|
assert "on disk but never imported: p1_1.jpg" in result.stdout
|
||||||
|
result.mark_seen_after_import()
|
||||||
|
assert _count_ledger(sync_engine, source_id) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recapture_imports_a_seen_file_nothing_imported(
|
||||||
|
source_id, sync_engine, tmp_path,
|
||||||
|
):
|
||||||
|
"""The repair for files already orphaned: they are in the ledger, so only
|
||||||
|
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_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)})
|
||||||
|
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",
|
||||||
|
url="https://patreon.com/ingest", mode="recapture")
|
||||||
|
|
||||||
|
assert result.written_paths == [str(tmp_path / "p1_1.jpg")]
|
||||||
|
assert [r[0] for r in result.relink_source_paths] == [str(tmp_path / "p1_2.jpg")]
|
||||||
|
assert downloader.download_calls == 0
|
||||||
|
|
||||||
|
|
||||||
# --- dead-letter ledger (plan #705 #7) ------------------------------------
|
# --- dead-letter ledger (plan #705 #7) ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user