dev→main: cursor-paged Patreon backfill + mobile modal fixes #70
@@ -32,6 +32,7 @@ from .gallery_dl import (
|
|||||||
ErrorType,
|
ErrorType,
|
||||||
GalleryDLService,
|
GalleryDLService,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
|
parse_last_cursor,
|
||||||
)
|
)
|
||||||
from .importer import Importer
|
from .importer import Importer
|
||||||
from .patreon_resolver import resolve_campaign_id
|
from .patreon_resolver import resolve_campaign_id
|
||||||
@@ -112,10 +113,20 @@ class DownloadService:
|
|||||||
# post history (skip: True + 1170s); when 0, exit gallery-dl after
|
# post history (skip: True + 1170s); when 0, exit gallery-dl after
|
||||||
# 20 contiguous archived items (skip: "exit:20" + the default
|
# 20 contiguous archived items (skip: "exit:20" + the default
|
||||||
# 870s). Operator sets backfill via POST /api/sources/{id}/backfill.
|
# 870s). Operator sets backfill via POST /api/sources/{id}/backfill.
|
||||||
|
overrides = ctx["config_overrides"] or {}
|
||||||
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
||||||
if backfill_remaining > 0:
|
# Cursor-paged backfill (plan #689): a pending checkpoint keeps the
|
||||||
|
# deep-walk in backfill mode across ticks until it reaches the
|
||||||
|
# bottom, even after the operator's run budget hits 0 — so one large
|
||||||
|
# creator (Anduo) finishes without re-triggering. The phase-3
|
||||||
|
# stuck-guard bounds it. patreon-only: it's the sole platform with a
|
||||||
|
# resumable gallery-dl cursor.
|
||||||
|
pending_cursor = overrides.get("_backfill_cursor")
|
||||||
|
if backfill_remaining > 0 or pending_cursor:
|
||||||
skip_value: bool | str = BACKFILL_SKIP_VALUE
|
skip_value: bool | str = BACKFILL_SKIP_VALUE
|
||||||
source_config.timeout = BACKFILL_TIMEOUT_SECONDS
|
source_config.timeout = BACKFILL_TIMEOUT_SECONDS
|
||||||
|
if ctx["platform"] == "patreon" and pending_cursor:
|
||||||
|
source_config.resume_cursor = pending_cursor
|
||||||
else:
|
else:
|
||||||
skip_value = TICK_SKIP_VALUE
|
skip_value = TICK_SKIP_VALUE
|
||||||
|
|
||||||
@@ -395,35 +406,74 @@ class DownloadService:
|
|||||||
source_id=ctx["source_id"], status=status, error_message=ev.error,
|
source_id=ctx["source_id"], status=status, error_message=ev.error,
|
||||||
error_type=dl_result.error_type.value if dl_result.error_type else None,
|
error_type=dl_result.error_type.value if dl_result.error_type else None,
|
||||||
)
|
)
|
||||||
# Plan #544: backfill lifecycle — auto-complete when a clean
|
await self._apply_backfill_lifecycle(ctx, dl_result)
|
||||||
# backfill run drained the queue (gallery-dl exited 0 + zero files
|
await self.async_session.commit()
|
||||||
# downloaded means there was nothing to fetch); otherwise decrement
|
return event_id
|
||||||
# the counter. Next tick falls back to tick mode once it hits 0.
|
|
||||||
#
|
async def _apply_backfill_lifecycle(self, ctx: dict, dl_result) -> None:
|
||||||
# Audit 2026-06-02 gating: VALIDATION_FAILED also exits the
|
"""Cursor-paged backfill state machine (plan #689).
|
||||||
# subprocess with return_code=0 and files_downloaded=0 (every
|
|
||||||
# file was quarantined), which used to match the auto-complete
|
A backfill is "in progress" while backfill_runs_remaining > 0 OR a
|
||||||
# predicate exactly — zeroing the operator's armed budget on
|
checkpoint cursor is stored. Completion is a CLEAN rc=0 finish —
|
||||||
# the FIRST quarantine run instead of decrementing. Require
|
gallery-dl with skip:True exits 0 only after exhausting the
|
||||||
# dl_result.success + no error_type so only genuinely-empty
|
newest→oldest walk; a run cut short by the subprocess budget returns
|
||||||
# successful runs drain the counter.
|
success=False / return_code=-1 (the TimeoutExpired path), so rc=0 is
|
||||||
|
an unambiguous "reached the bottom" signal regardless of how many
|
||||||
|
files this run fetched. (This replaces plan #544's files==0 predicate,
|
||||||
|
whose audit-2026-06-02 VALIDATION_FAILED guard still holds: those exit
|
||||||
|
non-zero or carry an error_type, so they are not "completed".)
|
||||||
|
|
||||||
|
For patreon, each non-completing run checkpoints gallery-dl's
|
||||||
|
last-emitted pagination cursor — even a timed-out run, since its
|
||||||
|
partial stdout/stderr still carries it — so the next run RESUMES
|
||||||
|
instead of re-walking from the top. A stuck-guard clears the cursor
|
||||||
|
after two consecutive runs that fail to advance it, so a wedged walk
|
||||||
|
can't re-strand forever (cf. download soft-limit ladder).
|
||||||
|
"""
|
||||||
|
overrides = ctx["config_overrides"] or {}
|
||||||
|
old_cursor = overrides.get("_backfill_cursor")
|
||||||
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
||||||
if backfill_remaining > 0:
|
if backfill_remaining <= 0 and not old_cursor:
|
||||||
|
return # tick mode — counter untouched
|
||||||
|
|
||||||
src = (await self.async_session.execute(
|
src = (await self.async_session.execute(
|
||||||
select(Source).where(Source.id == ctx["source_id"])
|
select(Source).where(Source.id == ctx["source_id"])
|
||||||
)).scalar_one()
|
)).scalar_one()
|
||||||
queue_drained = (
|
new_overrides = dict(src.config_overrides or {})
|
||||||
|
|
||||||
|
completed = (
|
||||||
dl_result.success
|
dl_result.success
|
||||||
and dl_result.error_type is None
|
and dl_result.error_type is None
|
||||||
and dl_result.return_code == 0
|
and dl_result.return_code == 0
|
||||||
and dl_result.files_downloaded == 0
|
|
||||||
)
|
)
|
||||||
if queue_drained:
|
if completed:
|
||||||
|
new_overrides.pop("_backfill_cursor", None)
|
||||||
|
new_overrides.pop("_backfill_cursor_stalls", None)
|
||||||
|
src.config_overrides = new_overrides
|
||||||
src.backfill_runs_remaining = 0
|
src.backfill_runs_remaining = 0
|
||||||
|
return
|
||||||
|
|
||||||
|
# Non-completing run. Patreon checkpoints + resumes via cursor;
|
||||||
|
# other platforms have no resumable cursor, so just decrement.
|
||||||
|
if ctx["platform"] == "patreon":
|
||||||
|
new_cursor = parse_last_cursor(dl_result.stdout, dl_result.stderr)
|
||||||
|
if new_cursor and new_cursor != old_cursor:
|
||||||
|
new_overrides["_backfill_cursor"] = new_cursor
|
||||||
|
new_overrides.pop("_backfill_cursor_stalls", None)
|
||||||
else:
|
else:
|
||||||
|
stalls = int(new_overrides.get("_backfill_cursor_stalls", 0)) + 1
|
||||||
|
if stalls >= 2:
|
||||||
|
# Wedged: give up, drop back to tick mode. The surfaced
|
||||||
|
# error_type on the event tells the operator why.
|
||||||
|
new_overrides.pop("_backfill_cursor", None)
|
||||||
|
new_overrides.pop("_backfill_cursor_stalls", None)
|
||||||
|
src.config_overrides = new_overrides
|
||||||
|
src.backfill_runs_remaining = 0
|
||||||
|
return
|
||||||
|
new_overrides["_backfill_cursor_stalls"] = stalls
|
||||||
|
src.config_overrides = new_overrides
|
||||||
|
|
||||||
src.backfill_runs_remaining = max(0, backfill_remaining - 1)
|
src.backfill_runs_remaining = max(0, backfill_remaining - 1)
|
||||||
await self.async_session.commit()
|
|
||||||
return event_id
|
|
||||||
|
|
||||||
async def _update_source_health(
|
async def _update_source_health(
|
||||||
self, *, source_id: int, status: str, error_message: str | None,
|
self, *, source_id: int, status: str, error_message: str | None,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -98,6 +99,15 @@ class SourceConfig:
|
|||||||
Source.backfill_runs_remaining column at the download_service layer
|
Source.backfill_runs_remaining column at the download_service layer
|
||||||
and is passed to _build_config_for_source as `skip_value`. Same for
|
and is passed to _build_config_for_source as `skip_value`. Same for
|
||||||
the per-run subprocess timeout.
|
the per-run subprocess timeout.
|
||||||
|
|
||||||
|
`resume_cursor` is RUNTIME state, not persisted operator config: the
|
||||||
|
cursor-paged backfill checkpoint (see parse_last_cursor + the
|
||||||
|
download_service backfill lifecycle). When set on a patreon backfill
|
||||||
|
run, gallery-dl resumes its newest→oldest walk from that pagination
|
||||||
|
cursor instead of restarting from the top. It is threaded by
|
||||||
|
download_service from config_overrides["_backfill_cursor"]; SourceConfig
|
||||||
|
deliberately does NOT read it in from_dict (config_overrides also holds
|
||||||
|
operator config, and resume_cursor must only apply in backfill mode).
|
||||||
"""
|
"""
|
||||||
content_types: list[str] = field(default_factory=lambda: ["all"])
|
content_types: list[str] = field(default_factory=lambda: ["all"])
|
||||||
sleep: float | None = None
|
sleep: float | None = None
|
||||||
@@ -106,6 +116,7 @@ class SourceConfig:
|
|||||||
filename_pattern: str | None = None
|
filename_pattern: str | None = None
|
||||||
save_metadata: bool = True
|
save_metadata: bool = True
|
||||||
timeout: int = _DEFAULT_GDL_TIMEOUT_SECONDS
|
timeout: int = _DEFAULT_GDL_TIMEOUT_SECONDS
|
||||||
|
resume_cursor: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> SourceConfig:
|
def from_dict(cls, data: dict) -> SourceConfig:
|
||||||
@@ -154,6 +165,21 @@ def _summarize_validation_failures(failures: list[dict]) -> str:
|
|||||||
return f"{n} files quarantined ({top_count}× {top_reason}, mixed)"
|
return f"{n} files quarantined ({top_count}× {top_reason}, mixed)"
|
||||||
|
|
||||||
|
|
||||||
|
# gallery-dl logs its pagination cursor (when extractor.*.cursor is truthy)
|
||||||
|
# as `Cursor: <token>` — once per fetched page, at debug verbosity. The LAST
|
||||||
|
# such line is the furthest-progressed page, i.e. the resume point. We scan
|
||||||
|
# both streams (the debug log lands on stderr, but be defensive) and take the
|
||||||
|
# final match; passing it back as extractor.patreon.cursor resumes the walk.
|
||||||
|
# Survives a timed-out run too: the TimeoutExpired path returns the partial
|
||||||
|
# stdout/stderr, so an interrupted backfill still yields its last cursor.
|
||||||
|
_CURSOR_RE = re.compile(r"Cursor:\s*(\S+)")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_last_cursor(stdout: str, stderr: str) -> str | None:
|
||||||
|
matches = _CURSOR_RE.findall(f"{stdout or ''}\n{stderr or ''}")
|
||||||
|
return matches[-1] if matches else None
|
||||||
|
|
||||||
|
|
||||||
class GalleryDLService:
|
class GalleryDLService:
|
||||||
"""Service for executing gallery-dl downloads."""
|
"""Service for executing gallery-dl downloads."""
|
||||||
|
|
||||||
@@ -349,6 +375,15 @@ class GalleryDLService:
|
|||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
platform_section["files"] = source_config.content_types
|
platform_section["files"] = source_config.content_types
|
||||||
|
# Cursor-paged backfill: resume the newest→oldest walk from the
|
||||||
|
# checkpoint saved by the previous run instead of restarting from
|
||||||
|
# the top. Without this, a large catalog (Anduo's weekly
|
||||||
|
# image-dense Reports) never reaches its frontier inside the
|
||||||
|
# subprocess budget — the HEAD walk alone times out, 0 files
|
||||||
|
# written, no progress (event #40411). The PLATFORM_DEFAULTS leave
|
||||||
|
# `cursor: True` (log-only) for the first/fresh run.
|
||||||
|
if source_config.resume_cursor:
|
||||||
|
platform_section["cursor"] = source_config.resume_cursor
|
||||||
elif platform == "hentaifoundry":
|
elif platform == "hentaifoundry":
|
||||||
if "pictures" in source_config.content_types or "all" in source_config.content_types:
|
if "pictures" in source_config.content_types or "all" in source_config.content_types:
|
||||||
platform_section["include"] = "all"
|
platform_section["include"] = "all"
|
||||||
|
|||||||
@@ -203,14 +203,37 @@ function nextFrame() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.fc-viewer__body { flex-direction: column; }
|
/* Stacked layout (operator-flagged 2026-06-05): pin the image pane and
|
||||||
/* Side panel drops below the image — the next arrow uses the full width. */
|
its controls at the top while the metadata panel scrolls beneath it.
|
||||||
.fc-viewer__nav--next { right: 16px; }
|
Previously the image + a 40vh panel shared one fixed viewport and the
|
||||||
|
controls could be pushed out of view; now the body scrolls and the
|
||||||
|
media is sticky, so the image + prev/next/close (and the integrity
|
||||||
|
badge) stay visible no matter how far the panel is scrolled. */
|
||||||
|
.fc-viewer__body {
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.fc-viewer__media {
|
||||||
|
flex: none;
|
||||||
|
height: 55vh;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
/* Opaque obsidian so the scrolling panel never bleeds through the
|
||||||
|
haze behind the pinned image. */
|
||||||
|
background: rgb(20, 23, 26);
|
||||||
|
}
|
||||||
.fc-viewer__side {
|
.fc-viewer__side {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 40vh;
|
max-height: none;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
||||||
}
|
}
|
||||||
|
/* Re-center the prev/next arrows over the 55vh image band (their base
|
||||||
|
top:50% would land on the scrolling panel); next uses the full width
|
||||||
|
now that the panel is below, not beside. Close + integrity badge keep
|
||||||
|
their top:16px/72px — already within the pinned band. */
|
||||||
|
.fc-viewer__nav { top: 27.5vh; }
|
||||||
|
.fc-viewer__nav--next { right: 16px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -65,8 +65,17 @@ const store = useTagStore()
|
|||||||
// Vuetify's v-text-field exposes .focus() on the component instance;
|
// Vuetify's v-text-field exposes .focus() on the component instance;
|
||||||
// nextTick waits for the modal's mount to finish so the inner <input>
|
// nextTick waits for the modal's mount to finish so the inner <input>
|
||||||
// element exists.
|
// element exists.
|
||||||
|
//
|
||||||
|
// EXCEPTION — not on mobile (operator-flagged 2026-06-05): focusing the
|
||||||
|
// field pops the soft keyboard, which shrinks the visual viewport and
|
||||||
|
// shoves the pinned image + its nav/close controls out of view. 900px is
|
||||||
|
// the modal's stacked-layout breakpoint (ImageViewer.vue). matchMedia is
|
||||||
|
// safe on plain HTTP (not a secure-context API).
|
||||||
const inputRef = ref(null)
|
const inputRef = ref(null)
|
||||||
onMounted(() => { nextTick(() => inputRef.value?.focus?.()) })
|
onMounted(() => {
|
||||||
|
if (window.matchMedia?.('(max-width: 900px)')?.matches) return
|
||||||
|
nextTick(() => inputRef.value?.focus?.())
|
||||||
|
})
|
||||||
|
|
||||||
// Single text input; no kind dropdown. Client-side mirror of the
|
// Single text input; no kind dropdown. Client-side mirror of the
|
||||||
// backend's parse_kind_prefix lives below — kept in sync with
|
// backend's parse_kind_prefix lives below — kept in sync with
|
||||||
|
|||||||
+148
-12
@@ -376,12 +376,13 @@ async def test_finalize_skipped_preserves_failures_clears_error(db):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_backfill_decrements_after_run(
|
async def test_backfill_decrements_and_checkpoints_cursor(
|
||||||
db, db_sync, tmp_path, seed_artist_and_source,
|
db, db_sync, tmp_path, seed_artist_and_source,
|
||||||
):
|
):
|
||||||
"""When backfill_runs_remaining > 0 going in, a non-clean / non-empty
|
"""A backfill run that did NOT finish the walk (subprocess budget cut it
|
||||||
run decrements by 1 — operator gets N runs to complete the deep scan
|
short → success=False / rc!=0) decrements the run budget AND checkpoints
|
||||||
before tick mode resumes."""
|
gallery-dl's last-emitted cursor, so the next run resumes instead of
|
||||||
|
re-walking from the top (plan #689, Anduo #40411)."""
|
||||||
from backend.app.services.download_service import DownloadService
|
from backend.app.services.download_service import DownloadService
|
||||||
from backend.app.services.importer import Importer
|
from backend.app.services.importer import Importer
|
||||||
|
|
||||||
@@ -390,12 +391,9 @@ async def test_backfill_decrements_after_run(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
images_root = tmp_path / "images"
|
images_root = tmp_path / "images"
|
||||||
f1 = images_root / "alice" / "patreon" / "post" / "a.jpg"
|
|
||||||
_make_jpg(f1, split="h")
|
|
||||||
|
|
||||||
fake_gdl = _fake_gdl_with_result(_make_fake_dl_result(
|
fake_gdl = _fake_gdl_with_result(_make_fake_dl_result(
|
||||||
success=True, written_paths=[str(f1)], files_downloaded=1,
|
success=False, written_paths=[], files_downloaded=0,
|
||||||
stdout=f"{f1}\n",
|
stderr="[patreon][debug] Cursor: 03:PAGE2:xyz\n",
|
||||||
))
|
))
|
||||||
|
|
||||||
sync_settings = db_sync.execute(
|
sync_settings = db_sync.execute(
|
||||||
@@ -412,10 +410,148 @@ async def test_backfill_decrements_after_run(
|
|||||||
)
|
)
|
||||||
await svc.download_source(source.id)
|
await svc.download_source(source.id)
|
||||||
|
|
||||||
remaining = (await db.execute(
|
remaining, overrides = (await db.execute(
|
||||||
select(Source.backfill_runs_remaining).where(Source.id == source.id)
|
select(Source.backfill_runs_remaining, Source.config_overrides)
|
||||||
)).scalar_one()
|
.where(Source.id == source.id)
|
||||||
|
)).one()
|
||||||
assert remaining == 2
|
assert remaining == 2
|
||||||
|
assert overrides.get("_backfill_cursor") == "03:PAGE2:xyz"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backfill_cursor_keeps_backfill_mode_after_budget_drained(
|
||||||
|
db, db_sync, tmp_path, seed_artist_and_source,
|
||||||
|
):
|
||||||
|
"""A pending cursor with runs_remaining == 0 still runs in BACKFILL mode
|
||||||
|
(skip:True + the resume cursor threaded to gallery-dl), so a large walk
|
||||||
|
finishes across ticks without the operator re-triggering."""
|
||||||
|
from backend.app.services.download_service import DownloadService
|
||||||
|
from backend.app.services.gallery_dl import BACKFILL_SKIP_VALUE
|
||||||
|
from backend.app.services.importer import Importer
|
||||||
|
|
||||||
|
_artist, source = seed_artist_and_source
|
||||||
|
source.backfill_runs_remaining = 0
|
||||||
|
source.config_overrides = {"_backfill_cursor": "03:RESUME:here"}
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
fake_gdl = _fake_gdl_with_result(_make_fake_dl_result(
|
||||||
|
success=False, written_paths=[],
|
||||||
|
stderr="[patreon][debug] Cursor: 03:RESUME2:next\n",
|
||||||
|
))
|
||||||
|
sync_settings = db_sync.execute(
|
||||||
|
select(ImportSettings).where(ImportSettings.id == 1)
|
||||||
|
).scalar_one()
|
||||||
|
importer = Importer(
|
||||||
|
session=db_sync, images_root=tmp_path / "images",
|
||||||
|
import_root=tmp_path / "images",
|
||||||
|
thumbnailer=Thumbnailer(images_root=tmp_path / "images"),
|
||||||
|
settings=sync_settings,
|
||||||
|
)
|
||||||
|
cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True))
|
||||||
|
svc = DownloadService(
|
||||||
|
async_session=db, sync_session=db_sync,
|
||||||
|
gdl=fake_gdl, importer=importer, cred_service=cred_service,
|
||||||
|
)
|
||||||
|
await svc.download_source(source.id)
|
||||||
|
|
||||||
|
kwargs = fake_gdl.download.call_args.kwargs
|
||||||
|
assert kwargs["skip_value"] is BACKFILL_SKIP_VALUE
|
||||||
|
assert kwargs["source_config"].resume_cursor == "03:RESUME:here"
|
||||||
|
|
||||||
|
overrides = (await db.execute(
|
||||||
|
select(Source.config_overrides).where(Source.id == source.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert overrides.get("_backfill_cursor") == "03:RESUME2:next"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backfill_completes_and_clears_cursor(
|
||||||
|
db, db_sync, tmp_path, seed_artist_and_source,
|
||||||
|
):
|
||||||
|
"""A clean rc=0 finish means gallery-dl walked to the bottom — clear the
|
||||||
|
checkpoint and drop back to tick mode, regardless of files this run."""
|
||||||
|
from backend.app.services.download_service import DownloadService
|
||||||
|
from backend.app.services.importer import Importer
|
||||||
|
|
||||||
|
_artist, source = seed_artist_and_source
|
||||||
|
source.backfill_runs_remaining = 2
|
||||||
|
source.config_overrides = {"_backfill_cursor": "03:NEAR:bottom"}
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
fake_gdl = _fake_gdl_with_result(_make_fake_dl_result(
|
||||||
|
success=True, written_paths=[], files_downloaded=0,
|
||||||
|
stderr="[patreon][debug] Cursor: 03:LAST:page\n",
|
||||||
|
))
|
||||||
|
sync_settings = db_sync.execute(
|
||||||
|
select(ImportSettings).where(ImportSettings.id == 1)
|
||||||
|
).scalar_one()
|
||||||
|
importer = Importer(
|
||||||
|
session=db_sync, images_root=tmp_path / "images",
|
||||||
|
import_root=tmp_path / "images",
|
||||||
|
thumbnailer=Thumbnailer(images_root=tmp_path / "images"),
|
||||||
|
settings=sync_settings,
|
||||||
|
)
|
||||||
|
cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True))
|
||||||
|
svc = DownloadService(
|
||||||
|
async_session=db, sync_session=db_sync,
|
||||||
|
gdl=fake_gdl, importer=importer, cred_service=cred_service,
|
||||||
|
)
|
||||||
|
await svc.download_source(source.id)
|
||||||
|
|
||||||
|
remaining, overrides = (await db.execute(
|
||||||
|
select(Source.backfill_runs_remaining, Source.config_overrides)
|
||||||
|
.where(Source.id == source.id)
|
||||||
|
)).one()
|
||||||
|
assert remaining == 0
|
||||||
|
assert "_backfill_cursor" not in (overrides or {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backfill_cursor_stuck_guard_gives_up(
|
||||||
|
db, db_sync, tmp_path, seed_artist_and_source,
|
||||||
|
):
|
||||||
|
"""If two consecutive non-completing runs fail to advance the cursor,
|
||||||
|
drop the checkpoint + zero the budget so a wedged walk can't re-strand
|
||||||
|
forever (cf. download soft-limit ladder)."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from backend.app.services.download_service import DownloadService
|
||||||
|
|
||||||
|
_artist, source = seed_artist_and_source
|
||||||
|
source.backfill_runs_remaining = 0
|
||||||
|
source.config_overrides = {"_backfill_cursor": "stuck"}
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
svc = DownloadService(
|
||||||
|
async_session=db, sync_session=db_sync,
|
||||||
|
gdl=MagicMock(), importer=MagicMock(), cred_service=MagicMock(),
|
||||||
|
)
|
||||||
|
ctx = {
|
||||||
|
"source_id": source.id, "platform": "patreon",
|
||||||
|
"config_overrides": {"_backfill_cursor": "stuck"},
|
||||||
|
"backfill_runs_remaining": 0,
|
||||||
|
}
|
||||||
|
stuck = _make_fake_dl_result(success=False, stderr="Cursor: stuck\n")
|
||||||
|
|
||||||
|
# Run 1: cursor unchanged → 1 stall, checkpoint retained.
|
||||||
|
await svc._apply_backfill_lifecycle(ctx, stuck)
|
||||||
|
await db.commit()
|
||||||
|
overrides = (await db.execute(
|
||||||
|
select(Source.config_overrides).where(Source.id == source.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert overrides.get("_backfill_cursor") == "stuck"
|
||||||
|
assert overrides.get("_backfill_cursor_stalls") == 1
|
||||||
|
|
||||||
|
# Run 2: still stuck → give up.
|
||||||
|
ctx["config_overrides"] = dict(overrides)
|
||||||
|
await svc._apply_backfill_lifecycle(ctx, stuck)
|
||||||
|
await db.commit()
|
||||||
|
remaining, overrides2 = (await db.execute(
|
||||||
|
select(Source.backfill_runs_remaining, Source.config_overrides)
|
||||||
|
.where(Source.id == source.id)
|
||||||
|
)).one()
|
||||||
|
assert remaining == 0
|
||||||
|
assert "_backfill_cursor" not in (overrides2 or {})
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from backend.app.services.gallery_dl import (
|
|||||||
ErrorType,
|
ErrorType,
|
||||||
GalleryDLService,
|
GalleryDLService,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
|
parse_last_cursor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -326,3 +327,38 @@ def test_default_config_forwards_patreon_referer_to_ytdl(gdl):
|
|||||||
headers = cfg["downloader"]["ytdl"]["raw-options"]["http_headers"]
|
headers = cfg["downloader"]["ytdl"]["raw-options"]["http_headers"]
|
||||||
assert headers["Referer"] == "https://www.patreon.com/"
|
assert headers["Referer"] == "https://www.patreon.com/"
|
||||||
assert headers["Origin"] == "https://www.patreon.com"
|
assert headers["Origin"] == "https://www.patreon.com"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Cursor-paged backfill (plan #689) -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_last_cursor_returns_last_match_across_streams():
|
||||||
|
# gallery-dl logs `Cursor: <token>` per page (debug → stderr); the last
|
||||||
|
# is the furthest-progressed resume point.
|
||||||
|
stderr = (
|
||||||
|
"[patreon][debug] Cursor: 03:AAAA:bbb\n"
|
||||||
|
"[urllib3] GET ...\n"
|
||||||
|
"[patreon][debug] Cursor: 03:CCCC:ddd\n"
|
||||||
|
)
|
||||||
|
assert parse_last_cursor("", stderr) == "03:CCCC:ddd"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_last_cursor_scans_stdout_too_and_none_when_absent():
|
||||||
|
assert parse_last_cursor("Cursor: 99:ZZZ", "") == "99:ZZZ"
|
||||||
|
assert parse_last_cursor("no marker here", "still nothing") is None
|
||||||
|
assert parse_last_cursor("", "") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_config_patreon_resume_cursor_overrides_default(gdl):
|
||||||
|
# No resume cursor → PLATFORM_DEFAULTS leaves the log-only `cursor: True`.
|
||||||
|
fresh = gdl._build_config_for_source(
|
||||||
|
"patreon", SourceConfig(), "alice", skip_value=True,
|
||||||
|
)
|
||||||
|
assert fresh["extractor"]["patreon"]["cursor"] is True
|
||||||
|
|
||||||
|
# Resume cursor set → gallery-dl restarts the walk from that page.
|
||||||
|
resumed = gdl._build_config_for_source(
|
||||||
|
"patreon", SourceConfig(resume_cursor="03:CCCC:ddd"), "alice",
|
||||||
|
skip_value=True,
|
||||||
|
)
|
||||||
|
assert resumed["extractor"]["patreon"]["cursor"] == "03:CCCC:ddd"
|
||||||
|
|||||||
Reference in New Issue
Block a user