feat(download): tick/backfill modes + partial-success classifier (plan #544)
Routine subscription polls walked the entire post history every tick
even when nothing had changed, because gallery-dl's default `skip: True`
continues iterating archived posts. A creator with ~550 archived posts
(Knuxy patreon) saturates the 870s wall-clock cap before completing,
even with zero downloads needed. Plus, a tier-limited run that
downloaded hundreds of files but ran out the clock should be a
warning, not an error.
Two coupled changes, both operator-flagged 2026-06-01:
* **Tick mode (default, cron polls).** New `TICK_SKIP_VALUE = "exit:20"`
asks gallery-dl to exit after 20 contiguous archived items. Fresh
subscriptions + new-content cases still walk normally; established
subscription with zero new content exits in ~30s of HEAD requests
instead of pegging the timeout. 20 (not 5) gives headroom against
paywall warnings interleaving with archived items.
* **Backfill mode (explicit, operator-triggered).** Sticky for N runs
via new `Source.backfill_runs_remaining` (alembic 0031). While > 0,
downloads use `skip: True` + 1800s timeout. Auto-decrements per run
with early-reset to 0 when a clean run finds zero files (queue
drained). N defaults to 3 — multiple runs give the system enough
budget to finish a deep walk across timeout boundaries. New
`POST /api/sources/{id}/backfill` arms the source; "Deep scan"
button on each SourceRow (chip shows remaining count) wires it.
Plus partial-success classifier: non-zero gallery-dl exit + ≥1 file
downloaded + no source-level error fires `ErrorType.PARTIAL`, which
download_service maps to `status=\"ok\"`. The run did real work; the
next tick continues via gallery-dl's archive. No more red events for
"timed out mid-walk after downloading 300 files."
Retires `SourceConfig.skip_existing` — skip value is now derived from
the source state and passed as a separate `skip_value` parameter
through download() / _build_config_for_source(). `GD_DEFAULTS` drops
the now-dead key (was inert data after this refactor).
Tests cover:
* tick + backfill skip-value emission in _build_config_for_source
* PARTIAL classifier branch + TIER_LIMITED-wins-over-PARTIAL ordering
* SourceService.set_backfill_runs validation + persistence
* /api/sources/{id}/backfill 200/400/404 paths
* download_service auto-decrement / auto-reset / tick-mode-no-touch
* PARTIAL → status=ok in the orchestrator (no consecutive_failures bump)
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
"""source.backfill_runs_remaining: sticky deep-scan mode
|
||||||
|
|
||||||
|
Revision ID: 0031
|
||||||
|
Revises: 0030
|
||||||
|
Create Date: 2026-06-01
|
||||||
|
|
||||||
|
Tick vs backfill mode for subscription downloads. When
|
||||||
|
`backfill_runs_remaining > 0`, the next N download runs use
|
||||||
|
`skip: True` + 30-min timeout (walk full history). When 0, runs use
|
||||||
|
`skip: "exit:20"` + 14.5-min timeout (catch-up mode, exits early once
|
||||||
|
20 contiguous archived items are seen).
|
||||||
|
|
||||||
|
Operator-flagged 2026-06-01 (Knuxy run #38887): a creator with ~550
|
||||||
|
archived posts saturates the 870s catch-up timeout even when there is
|
||||||
|
no new content, because gallery-dl's default `skip: True` keeps walking.
|
||||||
|
Tick mode short-circuits that; backfill mode is the explicit opt-in for
|
||||||
|
deep history scans.
|
||||||
|
|
||||||
|
Default 0 (all existing subscriptions start in tick mode).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0031"
|
||||||
|
down_revision: Union[str, None] = "0030"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"source",
|
||||||
|
sa.Column(
|
||||||
|
"backfill_runs_remaining",
|
||||||
|
sa.Integer,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("source", "backfill_runs_remaining")
|
||||||
@@ -120,6 +120,31 @@ async def delete_source(source_id: int):
|
|||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@sources_bp.route("/<int:source_id>/backfill", methods=["POST"])
|
||||||
|
async def set_backfill(source_id: int):
|
||||||
|
"""Plan #544: arm a source for backfill mode for the next N download
|
||||||
|
runs. Body: `{"runs": int}` (1..10, default 3). Returns the updated
|
||||||
|
source dict. While backfill_runs_remaining > 0, downloads use
|
||||||
|
gallery-dl's full-walk config (skip: True + 30-min timeout) instead
|
||||||
|
of the catch-up default (skip: "exit:20" + 14.5-min timeout)."""
|
||||||
|
payload = await request.get_json(silent=True) or {}
|
||||||
|
runs = payload.get("runs", 3)
|
||||||
|
try:
|
||||||
|
runs = int(runs)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _bad("invalid_runs", detail="runs must be an integer")
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
record = await SourceService(session).set_backfill_runs(
|
||||||
|
source_id, runs,
|
||||||
|
)
|
||||||
|
except LookupError:
|
||||||
|
return _bad("not_found", status=404)
|
||||||
|
except ValueError as exc:
|
||||||
|
return _bad("invalid_runs", detail=str(exc))
|
||||||
|
return jsonify(record.to_dict())
|
||||||
|
|
||||||
|
|
||||||
@sources_bp.route("/<int:source_id>/check", methods=["POST"])
|
@sources_bp.route("/<int:source_id>/check", methods=["POST"])
|
||||||
async def check_source(source_id: int):
|
async def check_source(source_id: int):
|
||||||
"""FC-3c: enqueue a download for this source.
|
"""FC-3c: enqueue a download for this source.
|
||||||
|
|||||||
@@ -29,4 +29,13 @@ class Source(Base):
|
|||||||
check_interval_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
check_interval_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
# alembic 0031: sticky deep-scan budget. When > 0, the next N download
|
||||||
|
# runs use gallery-dl's full-walk config (skip: True + 1800s timeout);
|
||||||
|
# when 0, runs use tick mode (skip: "exit:20" + 870s, exits early once
|
||||||
|
# 20 contiguous archived items are seen). Auto-decrements per run, with
|
||||||
|
# an auto-reset to 0 on clean exit + zero downloads (queue drained).
|
||||||
|
backfill_runs_remaining: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default="0",
|
||||||
|
)
|
||||||
|
|
||||||
artist = relationship("Artist", back_populates="sources")
|
artist = relationship("Artist", back_populates="sources")
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ from sqlalchemy.orm import joinedload
|
|||||||
|
|
||||||
from ..models import Artist, DownloadEvent, Source
|
from ..models import Artist, DownloadEvent, Source
|
||||||
from .credential_service import CredentialService
|
from .credential_service import CredentialService
|
||||||
from .gallery_dl import GalleryDLService, SourceConfig
|
from .gallery_dl import (
|
||||||
|
BACKFILL_SKIP_VALUE,
|
||||||
|
BACKFILL_TIMEOUT_SECONDS,
|
||||||
|
TICK_SKIP_VALUE,
|
||||||
|
ErrorType,
|
||||||
|
GalleryDLService,
|
||||||
|
SourceConfig,
|
||||||
|
)
|
||||||
from .importer import Importer
|
from .importer import Importer
|
||||||
from .patreon_resolver import resolve_campaign_id
|
from .patreon_resolver import resolve_campaign_id
|
||||||
from .scheduler_service import set_platform_cooldown
|
from .scheduler_service import set_platform_cooldown
|
||||||
@@ -84,6 +91,18 @@ class DownloadService:
|
|||||||
ctx = setup
|
ctx = setup
|
||||||
|
|
||||||
source_config = SourceConfig.from_dict(ctx["config_overrides"] or {})
|
source_config = SourceConfig.from_dict(ctx["config_overrides"] or {})
|
||||||
|
# alembic 0031 / plan #544: derive skip_value + timeout from the
|
||||||
|
# source's backfill_runs_remaining counter. When > 0, walk the full
|
||||||
|
# post history (skip: True + 1800s); when 0, exit gallery-dl after
|
||||||
|
# 20 contiguous archived items (skip: "exit:20" + the default
|
||||||
|
# 870s). Operator sets backfill via POST /api/sources/{id}/backfill.
|
||||||
|
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
||||||
|
if backfill_remaining > 0:
|
||||||
|
skip_value: bool | str = BACKFILL_SKIP_VALUE
|
||||||
|
source_config.timeout = BACKFILL_TIMEOUT_SECONDS
|
||||||
|
else:
|
||||||
|
skip_value = TICK_SKIP_VALUE
|
||||||
|
|
||||||
effective_url = _effective_url(
|
effective_url = _effective_url(
|
||||||
ctx["platform"], ctx["url"], ctx["config_overrides"] or {}
|
ctx["platform"], ctx["url"], ctx["config_overrides"] or {}
|
||||||
)
|
)
|
||||||
@@ -95,6 +114,7 @@ class DownloadService:
|
|||||||
source_config=source_config,
|
source_config=source_config,
|
||||||
cookies_path=ctx["cookies_path"],
|
cookies_path=ctx["cookies_path"],
|
||||||
auth_token=ctx["auth_token"],
|
auth_token=ctx["auth_token"],
|
||||||
|
skip_value=skip_value,
|
||||||
)
|
)
|
||||||
|
|
||||||
resolved_campaign_id: str | None = None
|
resolved_campaign_id: str | None = None
|
||||||
@@ -121,6 +141,7 @@ class DownloadService:
|
|||||||
source_config=source_config,
|
source_config=source_config,
|
||||||
cookies_path=ctx["cookies_path"],
|
cookies_path=ctx["cookies_path"],
|
||||||
auth_token=ctx["auth_token"],
|
auth_token=ctx["auth_token"],
|
||||||
|
skip_value=skip_value,
|
||||||
)
|
)
|
||||||
|
|
||||||
return await self._phase3_persist(
|
return await self._phase3_persist(
|
||||||
@@ -185,6 +206,7 @@ class DownloadService:
|
|||||||
"config_overrides": dict(source.config_overrides or {}),
|
"config_overrides": dict(source.config_overrides or {}),
|
||||||
"cookies_path": cookies_path,
|
"cookies_path": cookies_path,
|
||||||
"auth_token": auth_token,
|
"auth_token": auth_token,
|
||||||
|
"backfill_runs_remaining": source.backfill_runs_remaining or 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _phase3_persist(
|
async def _phase3_persist(
|
||||||
@@ -259,12 +281,21 @@ class DownloadService:
|
|||||||
run_stats["quarantined_count"] = dl_result.files_quarantined
|
run_stats["quarantined_count"] = dl_result.files_quarantined
|
||||||
stderr_summary = self.gdl._extract_errors_warnings(dl_result.stderr)
|
stderr_summary = self.gdl._extract_errors_warnings(dl_result.stderr)
|
||||||
|
|
||||||
status = "ok" if (dl_result.success and import_summary["errors"] == 0) else "error"
|
# Plan #544: PARTIAL means the run downloaded ≥1 file but the
|
||||||
|
# subprocess didn't finish in budget (typically wall-clock timeout
|
||||||
|
# mid-walk). Real work happened; the next tick continues via
|
||||||
|
# gallery-dl's archive. NOT a failure for status purposes.
|
||||||
|
if dl_result.success and import_summary["errors"] == 0:
|
||||||
|
status = "ok"
|
||||||
|
elif dl_result.error_type == ErrorType.PARTIAL and import_summary["errors"] == 0:
|
||||||
|
status = "ok"
|
||||||
|
else:
|
||||||
|
status = "error"
|
||||||
ev.status = status
|
ev.status = status
|
||||||
ev.finished_at = datetime.now(UTC)
|
ev.finished_at = datetime.now(UTC)
|
||||||
ev.files_count = import_summary["attached"]
|
ev.files_count = import_summary["attached"]
|
||||||
ev.bytes_downloaded = bytes_downloaded
|
ev.bytes_downloaded = bytes_downloaded
|
||||||
ev.error = dl_result.error_message if not dl_result.success else None
|
ev.error = dl_result.error_message if status == "error" else None
|
||||||
ev.metadata_ = {
|
ev.metadata_ = {
|
||||||
"run_stats": run_stats,
|
"run_stats": run_stats,
|
||||||
"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,
|
||||||
@@ -279,6 +310,19 @@ 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
|
||||||
|
# backfill run drained the queue (gallery-dl exited 0 + zero files
|
||||||
|
# downloaded means there was nothing to fetch); otherwise decrement
|
||||||
|
# the counter. Next tick falls back to tick mode once it hits 0.
|
||||||
|
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
||||||
|
if backfill_remaining > 0:
|
||||||
|
src = (await self.async_session.execute(
|
||||||
|
select(Source).where(Source.id == ctx["source_id"])
|
||||||
|
)).scalar_one()
|
||||||
|
if dl_result.return_code == 0 and dl_result.files_downloaded == 0:
|
||||||
|
src.backfill_runs_remaining = 0
|
||||||
|
else:
|
||||||
|
src.backfill_runs_remaining = max(0, backfill_remaining - 1)
|
||||||
await self.async_session.commit()
|
await self.async_session.commit()
|
||||||
return event_id
|
return event_id
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,29 @@ class ErrorType(StrEnum):
|
|||||||
HTTP_ERROR = "http_error"
|
HTTP_ERROR = "http_error"
|
||||||
UNSUPPORTED_URL = "unsupported_url"
|
UNSUPPORTED_URL = "unsupported_url"
|
||||||
VALIDATION_FAILED = "validation_failed"
|
VALIDATION_FAILED = "validation_failed"
|
||||||
|
# Run made real progress (downloaded ≥1 file) but did not finish in the
|
||||||
|
# subprocess budget. Distinct from UNKNOWN_ERROR — the downstream status
|
||||||
|
# mapping classifies this as "ok" because the next tick continues.
|
||||||
|
PARTIAL = "partial"
|
||||||
UNKNOWN_ERROR = "unknown_error"
|
UNKNOWN_ERROR = "unknown_error"
|
||||||
|
|
||||||
|
|
||||||
|
# Tick mode (routine cron polls): skip ≤20 contiguous already-archived
|
||||||
|
# items, then exit gallery-dl. Established subscription with zero new
|
||||||
|
# content exits in ~30s of HEAD requests instead of walking to the bottom
|
||||||
|
# of the post history (which can be hours for prolific creators). 20 (not
|
||||||
|
# 5) is operator-set headroom against any edge case where paywalled or
|
||||||
|
# otherwise-non-downloadable items might interleave with archived ones —
|
||||||
|
# 20 contiguous HEADs is still negligible.
|
||||||
|
TICK_SKIP_VALUE = "exit:20"
|
||||||
|
|
||||||
|
# Backfill mode (operator-triggered deep scan): walk the full history.
|
||||||
|
# Source.backfill_runs_remaining > 0 selects this mode; the longer
|
||||||
|
# timeout below absorbs creators with thousands of posts.
|
||||||
|
BACKFILL_SKIP_VALUE = True
|
||||||
|
BACKFILL_TIMEOUT_SECONDS = 1800
|
||||||
|
|
||||||
|
|
||||||
# 30 seconds shy of download_source's Celery soft_time_limit (900s, see
|
# 30 seconds shy of download_source's Celery soft_time_limit (900s, see
|
||||||
# tasks/download.py:32). subprocess.run MUST raise TimeoutExpired before
|
# tasks/download.py:32). subprocess.run MUST raise TimeoutExpired before
|
||||||
# Celery raises SoftTimeLimitExceeded — otherwise Celery wins the race,
|
# Celery raises SoftTimeLimitExceeded — otherwise Celery wins the race,
|
||||||
@@ -56,12 +76,19 @@ _DEFAULT_GDL_TIMEOUT_SECONDS = 870
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SourceConfig:
|
class SourceConfig:
|
||||||
|
"""Per-source overrides loaded from Source.config_overrides JSON.
|
||||||
|
|
||||||
|
Note: the gallery-dl `skip` value (tick vs backfill, see TICK_SKIP_VALUE /
|
||||||
|
BACKFILL_SKIP_VALUE) is NOT carried here — it derives from the
|
||||||
|
Source.backfill_runs_remaining column at the download_service layer
|
||||||
|
and is passed to _build_config_for_source as `skip_value`. Same for
|
||||||
|
the per-run subprocess timeout.
|
||||||
|
"""
|
||||||
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
|
||||||
sleep_request: float | None = None
|
sleep_request: float | None = None
|
||||||
directory_pattern: str | None = None
|
directory_pattern: str | None = None
|
||||||
filename_pattern: str | None = None
|
filename_pattern: str | None = None
|
||||||
skip_existing: bool = True
|
|
||||||
save_metadata: bool = True
|
save_metadata: bool = True
|
||||||
timeout: int = _DEFAULT_GDL_TIMEOUT_SECONDS
|
timeout: int = _DEFAULT_GDL_TIMEOUT_SECONDS
|
||||||
|
|
||||||
@@ -73,7 +100,6 @@ class SourceConfig:
|
|||||||
sleep_request=data.get("sleep_request"),
|
sleep_request=data.get("sleep_request"),
|
||||||
directory_pattern=data.get("directory_pattern"),
|
directory_pattern=data.get("directory_pattern"),
|
||||||
filename_pattern=data.get("filename_pattern"),
|
filename_pattern=data.get("filename_pattern"),
|
||||||
skip_existing=data.get("skip_existing", True),
|
|
||||||
save_metadata=data.get("save_metadata", True),
|
save_metadata=data.get("save_metadata", True),
|
||||||
timeout=data.get("timeout", _DEFAULT_GDL_TIMEOUT_SECONDS),
|
timeout=data.get("timeout", _DEFAULT_GDL_TIMEOUT_SECONDS),
|
||||||
)
|
)
|
||||||
@@ -245,7 +271,18 @@ class GalleryDLService:
|
|||||||
platform: str,
|
platform: str,
|
||||||
source_config: SourceConfig,
|
source_config: SourceConfig,
|
||||||
artist_slug: str,
|
artist_slug: str,
|
||||||
|
skip_value: bool | str = BACKFILL_SKIP_VALUE,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
"""`skip_value` controls gallery-dl's archive-walk behavior:
|
||||||
|
- True (BACKFILL_SKIP_VALUE): walk full post history, skipping
|
||||||
|
archived items but continuing past them. Used in backfill mode.
|
||||||
|
- "exit:20" (TICK_SKIP_VALUE): exit gallery-dl after 20
|
||||||
|
contiguous archived items. Used in tick (routine catch-up)
|
||||||
|
mode for fast no-op syncs on creators with deep history.
|
||||||
|
- False: don't skip — redownload everything (not used in FC).
|
||||||
|
The caller (download_service) chooses based on
|
||||||
|
Source.backfill_runs_remaining.
|
||||||
|
"""
|
||||||
config = json.loads(json.dumps(self._get_default_config())) # deep copy
|
config = json.loads(json.dumps(self._get_default_config())) # deep copy
|
||||||
|
|
||||||
destination = str(self.images_root / artist_slug / platform)
|
destination = str(self.images_root / artist_slug / platform)
|
||||||
@@ -255,7 +292,7 @@ class GalleryDLService:
|
|||||||
config["extractor"]["sleep"] = source_config.sleep
|
config["extractor"]["sleep"] = source_config.sleep
|
||||||
if source_config.sleep_request is not None:
|
if source_config.sleep_request is not None:
|
||||||
config["extractor"]["sleep-request"] = source_config.sleep_request
|
config["extractor"]["sleep-request"] = source_config.sleep_request
|
||||||
config["extractor"]["skip"] = source_config.skip_existing
|
config["extractor"]["skip"] = skip_value
|
||||||
|
|
||||||
if source_config.save_metadata:
|
if source_config.save_metadata:
|
||||||
config["extractor"]["postprocessors"] = [
|
config["extractor"]["postprocessors"] = [
|
||||||
@@ -394,6 +431,22 @@ class GalleryDLService:
|
|||||||
f"Subscription tier does not grant access to {count} post{'s' if count != 1 else ''}",
|
f"Subscription tier does not grant access to {count} post{'s' if count != 1 else ''}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Partial-success: the subprocess exited non-zero (typically because
|
||||||
|
# the wall-clock timeout fired mid-walk), but it had downloaded ≥1
|
||||||
|
# file by then and no source-level error category fired. The work
|
||||||
|
# the run DID do is real; gallery-dl's archive will pick up where
|
||||||
|
# it left off on the next tick. Mapped to status="ok" downstream
|
||||||
|
# (download_service.py) so this doesn't flag the source as
|
||||||
|
# "needs attention." Operator-flagged 2026-06-01 after a Knuxy
|
||||||
|
# patreon run downloaded hundreds of files then ran red on timeout.
|
||||||
|
files_downloaded = self._count_downloaded_files(stdout)
|
||||||
|
if not has_actual_error and files_downloaded > 0:
|
||||||
|
return (
|
||||||
|
ErrorType.PARTIAL,
|
||||||
|
f"Downloaded {files_downloaded} file{'s' if files_downloaded != 1 else ''}; "
|
||||||
|
"run did not complete in budget — next tick will continue",
|
||||||
|
)
|
||||||
|
|
||||||
return ErrorType.UNKNOWN_ERROR, f"Unknown error (return code: {return_code})"
|
return ErrorType.UNKNOWN_ERROR, f"Unknown error (return code: {return_code})"
|
||||||
|
|
||||||
def _count_downloaded_files(self, stdout: str) -> int:
|
def _count_downloaded_files(self, stdout: str) -> int:
|
||||||
@@ -545,6 +598,7 @@ class GalleryDLService:
|
|||||||
source_config: SourceConfig | None = None,
|
source_config: SourceConfig | None = None,
|
||||||
cookies_path: str | None = None,
|
cookies_path: str | None = None,
|
||||||
auth_token: str | None = None,
|
auth_token: str | None = None,
|
||||||
|
skip_value: bool | str = BACKFILL_SKIP_VALUE,
|
||||||
) -> DownloadResult:
|
) -> DownloadResult:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
started_at = datetime.now(UTC).isoformat()
|
started_at = datetime.now(UTC).isoformat()
|
||||||
@@ -552,7 +606,9 @@ class GalleryDLService:
|
|||||||
if source_config is None:
|
if source_config is None:
|
||||||
source_config = SourceConfig()
|
source_config = SourceConfig()
|
||||||
|
|
||||||
config = self._build_config_for_source(platform, source_config, artist_slug)
|
config = self._build_config_for_source(
|
||||||
|
platform, source_config, artist_slug, skip_value=skip_value,
|
||||||
|
)
|
||||||
|
|
||||||
if cookies_path:
|
if cookies_path:
|
||||||
config["extractor"]["cookies"] = cookies_path
|
config["extractor"]["cookies"] = cookies_path
|
||||||
|
|||||||
@@ -96,10 +96,12 @@ def str_field(v) -> str | None:
|
|||||||
# Shared gallery-dl invocation defaults. Embedded in each platform's
|
# Shared gallery-dl invocation defaults. Embedded in each platform's
|
||||||
# default_config (with platform-specific overrides) so per-platform
|
# default_config (with platform-specific overrides) so per-platform
|
||||||
# choices stay explicit.
|
# choices stay explicit.
|
||||||
|
# Note: the gallery-dl `skip` value (tick "exit:20" vs backfill True) is
|
||||||
|
# NOT here — it's derived from Source.backfill_runs_remaining at download
|
||||||
|
# time. See plan #544 / gallery_dl.TICK_SKIP_VALUE,BACKFILL_SKIP_VALUE.
|
||||||
GD_DEFAULTS = {
|
GD_DEFAULTS = {
|
||||||
"sleep": 3.0,
|
"sleep": 3.0,
|
||||||
"sleep_request": 1.5,
|
"sleep_request": 1.5,
|
||||||
"skip_existing": True,
|
|
||||||
"save_metadata": True,
|
"save_metadata": True,
|
||||||
"timeout": 3600,
|
"timeout": 3600,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ def attempt_refetch(
|
|||||||
if src is None:
|
if src is None:
|
||||||
return {"status": "no_source"}
|
return {"status": "no_source"}
|
||||||
|
|
||||||
# Remove the bad copy so gallery-dl (skip_existing) re-fetches it on
|
# Remove the bad copy so gallery-dl's archive-skip re-fetches it on
|
||||||
# the source re-check instead of skipping the still-present corrupt
|
# the source re-check instead of skipping the still-present corrupt
|
||||||
# file.
|
# file.
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class SourceRecord:
|
|||||||
check_interval_override: int | None
|
check_interval_override: int | None
|
||||||
consecutive_failures: int
|
consecutive_failures: int
|
||||||
next_check_at: str | None
|
next_check_at: str | None
|
||||||
|
backfill_runs_remaining: int
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -78,6 +79,7 @@ class SourceRecord:
|
|||||||
"check_interval_override": self.check_interval_override,
|
"check_interval_override": self.check_interval_override,
|
||||||
"consecutive_failures": self.consecutive_failures,
|
"consecutive_failures": self.consecutive_failures,
|
||||||
"next_check_at": self.next_check_at,
|
"next_check_at": self.next_check_at,
|
||||||
|
"backfill_runs_remaining": self.backfill_runs_remaining,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -140,6 +142,7 @@ class SourceService:
|
|||||||
check_interval_override=source.check_interval_override,
|
check_interval_override=source.check_interval_override,
|
||||||
consecutive_failures=source.consecutive_failures or 0,
|
consecutive_failures=source.consecutive_failures or 0,
|
||||||
next_check_at=nxt.isoformat() if nxt else None,
|
next_check_at=nxt.isoformat() if nxt else None,
|
||||||
|
backfill_runs_remaining=source.backfill_runs_remaining or 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _row_to_record(self, source: Source) -> SourceRecord:
|
async def _row_to_record(self, source: Source) -> SourceRecord:
|
||||||
@@ -264,6 +267,25 @@ class SourceService:
|
|||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
return await self._row_to_record(source)
|
return await self._row_to_record(source)
|
||||||
|
|
||||||
|
async def set_backfill_runs(
|
||||||
|
self, source_id: int, runs: int,
|
||||||
|
) -> SourceRecord:
|
||||||
|
"""Plan #544: arm a source for backfill mode. The next `runs`
|
||||||
|
download runs will use gallery-dl's full-walk config (skip: True
|
||||||
|
+ 30-min timeout) instead of the catch-up default. Runs must be
|
||||||
|
1..10 — bigger is rejected to keep the operator from accidentally
|
||||||
|
setting a runaway budget."""
|
||||||
|
if not isinstance(runs, int) or runs < 1 or runs > 10:
|
||||||
|
raise ValueError("runs must be an integer in [1, 10]")
|
||||||
|
source = (await self.session.execute(
|
||||||
|
select(Source).where(Source.id == source_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if source is None:
|
||||||
|
raise LookupError(f"source id={source_id} not found")
|
||||||
|
source.backfill_runs_remaining = runs
|
||||||
|
await self.session.commit()
|
||||||
|
return await self._row_to_record(source)
|
||||||
|
|
||||||
async def delete(self, source_id: int) -> None:
|
async def delete(self, source_id: int) -> None:
|
||||||
source = (await self.session.execute(
|
source = (await self.session.execute(
|
||||||
select(Source).where(Source.id == source_id)
|
select(Source).where(Source.id == source_id)
|
||||||
|
|||||||
@@ -31,6 +31,12 @@
|
|||||||
v-if="(source.consecutive_failures || 0) > 0"
|
v-if="(source.consecutive_failures || 0) > 0"
|
||||||
size="x-small" color="error" variant="tonal" label
|
size="x-small" color="error" variant="tonal" label
|
||||||
>{{ source.consecutive_failures }}</v-chip>
|
>{{ source.consecutive_failures }}</v-chip>
|
||||||
|
<v-chip
|
||||||
|
v-else-if="(source.backfill_runs_remaining || 0) > 0"
|
||||||
|
size="x-small" color="info" variant="tonal" label
|
||||||
|
>
|
||||||
|
backfill ({{ source.backfill_runs_remaining }}×)
|
||||||
|
</v-chip>
|
||||||
<span v-else class="fc-source-row__zero">0</span>
|
<span v-else class="fc-source-row__zero">0</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="fc-source-row__actions">
|
<td class="fc-source-row__actions">
|
||||||
@@ -42,6 +48,16 @@
|
|||||||
<v-icon>mdi-play</v-icon>
|
<v-icon>mdi-play</v-icon>
|
||||||
<v-tooltip activator="parent" location="top">Check now</v-tooltip>
|
<v-tooltip activator="parent" location="top">Check now</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-magnify-scan" size="x-small" variant="text"
|
||||||
|
:disabled="(source.backfill_runs_remaining || 0) > 0"
|
||||||
|
@click.stop="$emit('backfill', source)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-magnify-scan</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="top">
|
||||||
|
Deep scan — walk full history for next few runs
|
||||||
|
</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-pencil" size="x-small" variant="text"
|
icon="mdi-pencil" size="x-small" variant="text"
|
||||||
@click.stop="$emit('edit', source)"
|
@click.stop="$emit('edit', source)"
|
||||||
@@ -69,7 +85,7 @@ const props = defineProps({
|
|||||||
checking: { type: Boolean, default: false },
|
checking: { type: Boolean, default: false },
|
||||||
warningThreshold: { type: Number, default: 5 },
|
warningThreshold: { type: Number, default: 5 },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['edit', 'remove', 'toggle', 'check'])
|
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill'])
|
||||||
|
|
||||||
function onToggleEnabled(value) {
|
function onToggleEnabled(value) {
|
||||||
emit('toggle', { source: props.source, enabled: value })
|
emit('toggle', { source: props.source, enabled: value })
|
||||||
|
|||||||
@@ -174,6 +174,7 @@
|
|||||||
@remove="removeSource"
|
@remove="removeSource"
|
||||||
@toggle="toggleSourceEnabled"
|
@toggle="toggleSourceEnabled"
|
||||||
@check="onCheck"
|
@check="onCheck"
|
||||||
|
@backfill="onBackfill"
|
||||||
/>
|
/>
|
||||||
<tr v-if="item.sources.length === 0">
|
<tr v-if="item.sources.length === 0">
|
||||||
<td colspan="8" class="fc-subs__sources-empty">
|
<td colspan="8" class="fc-subs__sources-empty">
|
||||||
@@ -431,6 +432,36 @@ async function onCheck(source) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plan #544: arm a source for backfill mode (gallery-dl walks the full
|
||||||
|
// post history) for the next N download runs. Default 3 — enough budget
|
||||||
|
// to finish a deep creator without re-prompting the operator across
|
||||||
|
// timeout boundaries. The chip on the row reflects the remaining count.
|
||||||
|
async function onBackfill(source) {
|
||||||
|
const raw = globalThis.window?.prompt(
|
||||||
|
`Deep scan "${source.artist_name} (${source.platform})" — walk full history for the next how many download runs? (1–10, default 3)`,
|
||||||
|
'3',
|
||||||
|
)
|
||||||
|
if (raw == null) return
|
||||||
|
const runs = parseInt(raw, 10)
|
||||||
|
if (!Number.isFinite(runs) || runs < 1 || runs > 10) {
|
||||||
|
toast({ text: 'Deep scan: runs must be 1–10', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await store.setBackfill(source.id, runs, source.artist_id)
|
||||||
|
toast({
|
||||||
|
text: `Deep scan armed for ${runs} run${runs === 1 ? '' : 's'}`,
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
await store.loadAll()
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
text: `Deep scan failed: ${e?.detail || e?.message || e}`,
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function checkAll(group) {
|
async function checkAll(group) {
|
||||||
let ok = 0
|
let ok = 0
|
||||||
let conflict = 0
|
let conflict = 0
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ export const useSourcesStore = defineStore('sources', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plan #544: arm a source for backfill mode. The next `runs` download
|
||||||
|
// runs (default 3) walk gallery-dl's full post history instead of
|
||||||
|
// exiting early at the first contiguous archived block.
|
||||||
|
async function setBackfill(id, runs = 3, artistIdHint = null) {
|
||||||
|
const body = await api.post(`/api/sources/${id}/backfill`, { body: { runs } })
|
||||||
|
_invalidate(artistIdHint ?? body.artist_id)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
function sourcesByArtistGrouped() {
|
function sourcesByArtistGrouped() {
|
||||||
// returns [{artist: {id,name,slug}, sources: [...]}, ...]
|
// returns [{artist: {id,name,slug}, sources: [...]}, ...]
|
||||||
const arr = byArtist.value.get(null) ?? []
|
const arr = byArtist.value.get(null) ?? []
|
||||||
@@ -111,6 +120,7 @@ export const useSourcesStore = defineStore('sources', () => {
|
|||||||
loadAll, loadForArtist,
|
loadAll, loadForArtist,
|
||||||
create, update, remove,
|
create, update, remove,
|
||||||
checkNow,
|
checkNow,
|
||||||
|
setBackfill,
|
||||||
findOrCreateArtist, autocompleteArtist,
|
findOrCreateArtist, autocompleteArtist,
|
||||||
loadScheduleStatus,
|
loadScheduleStatus,
|
||||||
sourcesByArtistGrouped,
|
sourcesByArtistGrouped,
|
||||||
|
|||||||
@@ -193,3 +193,61 @@ async def test_list_derives_next_check_at_when_last_checked_set(
|
|||||||
)
|
)
|
||||||
assert target["next_check_at"] is not None
|
assert target["next_check_at"] is not None
|
||||||
assert "T" in target["next_check_at"] # ISO 8601
|
assert "T" in target["next_check_at"] # ISO 8601
|
||||||
|
|
||||||
|
|
||||||
|
# --- Plan #544: POST /api/sources/{id}/backfill ----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backfill_endpoint_arms_source(client, artist, db):
|
||||||
|
src = Source(
|
||||||
|
artist_id=artist.id, platform="patreon",
|
||||||
|
url="https://patreon.com/alice-backfill", enabled=True,
|
||||||
|
)
|
||||||
|
db.add(src)
|
||||||
|
await db.commit()
|
||||||
|
sid = src.id
|
||||||
|
|
||||||
|
resp = await client.post(f"/api/sources/{sid}/backfill", json={"runs": 5})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["backfill_runs_remaining"] == 5
|
||||||
|
|
||||||
|
# GET reflects the new state.
|
||||||
|
one = await client.get(f"/api/sources/{sid}")
|
||||||
|
assert (await one.get_json())["backfill_runs_remaining"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backfill_endpoint_defaults_to_three(client, artist, db):
|
||||||
|
src = Source(
|
||||||
|
artist_id=artist.id, platform="patreon",
|
||||||
|
url="https://patreon.com/alice-backfill-default", enabled=True,
|
||||||
|
)
|
||||||
|
db.add(src)
|
||||||
|
await db.commit()
|
||||||
|
resp = await client.post(f"/api/sources/{src.id}/backfill", json={})
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["backfill_runs_remaining"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backfill_endpoint_rejects_out_of_range(client, artist, db):
|
||||||
|
src = Source(
|
||||||
|
artist_id=artist.id, platform="patreon",
|
||||||
|
url="https://patreon.com/alice-backfill-bad", enabled=True,
|
||||||
|
)
|
||||||
|
db.add(src)
|
||||||
|
await db.commit()
|
||||||
|
bad = await client.post(f"/api/sources/{src.id}/backfill", json={"runs": 0})
|
||||||
|
assert bad.status_code == 400
|
||||||
|
too_big = await client.post(
|
||||||
|
f"/api/sources/{src.id}/backfill", json={"runs": 99}
|
||||||
|
)
|
||||||
|
assert too_big.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backfill_endpoint_404_when_source_missing(client):
|
||||||
|
resp = await client.post("/api/sources/999999/backfill", json={"runs": 3})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|||||||
@@ -370,3 +370,179 @@ async def test_finalize_skipped_preserves_failures_clears_error(db):
|
|||||||
assert row.consecutive_failures == 4
|
assert row.consecutive_failures == 4
|
||||||
assert row.last_error is None
|
assert row.last_error is None
|
||||||
assert row.last_checked_at is not None
|
assert row.last_checked_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Plan #544: backfill lifecycle + PARTIAL → status=ok -------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backfill_decrements_after_run(
|
||||||
|
db, db_sync, tmp_path, seed_artist_and_source,
|
||||||
|
):
|
||||||
|
"""When backfill_runs_remaining > 0 going in, a non-clean / non-empty
|
||||||
|
run decrements by 1 — operator gets N runs to complete the deep scan
|
||||||
|
before tick mode resumes."""
|
||||||
|
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 = 3
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
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(
|
||||||
|
success=True, written_paths=[str(f1)], files_downloaded=1,
|
||||||
|
stdout=f"{f1}\n",
|
||||||
|
))
|
||||||
|
|
||||||
|
sync_settings = db_sync.execute(
|
||||||
|
select(ImportSettings).where(ImportSettings.id == 1)
|
||||||
|
).scalar_one()
|
||||||
|
importer = Importer(
|
||||||
|
session=db_sync, images_root=images_root, import_root=images_root,
|
||||||
|
thumbnailer=Thumbnailer(images_root=images_root), settings=sync_settings,
|
||||||
|
)
|
||||||
|
cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64"))
|
||||||
|
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 = (await db.execute(
|
||||||
|
select(Source.backfill_runs_remaining).where(Source.id == source.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert remaining == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backfill_auto_resets_on_clean_zero_files(
|
||||||
|
db, db_sync, tmp_path, seed_artist_and_source,
|
||||||
|
):
|
||||||
|
"""A clean run (rc=0) that downloaded zero files means the backfill
|
||||||
|
queue drained — reset to 0 immediately instead of wasting the rest of
|
||||||
|
the N-run budget on no-op walks."""
|
||||||
|
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 = 3
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
fake_result = _make_fake_dl_result(
|
||||||
|
success=True, written_paths=[], files_downloaded=0,
|
||||||
|
)
|
||||||
|
fake_gdl = _fake_gdl_with_result(fake_result)
|
||||||
|
|
||||||
|
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"))
|
||||||
|
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 = (await db.execute(
|
||||||
|
select(Source.backfill_runs_remaining).where(Source.id == source.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert remaining == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tick_mode_does_not_touch_backfill_counter(
|
||||||
|
db, db_sync, tmp_path, seed_artist_and_source,
|
||||||
|
):
|
||||||
|
"""When backfill_runs_remaining is already 0, downloads don't go
|
||||||
|
negative or otherwise mutate the counter."""
|
||||||
|
from backend.app.services.download_service import DownloadService
|
||||||
|
from backend.app.services.importer import Importer
|
||||||
|
|
||||||
|
_artist, source = seed_artist_and_source
|
||||||
|
assert source.backfill_runs_remaining == 0
|
||||||
|
|
||||||
|
fake_gdl = _fake_gdl_with_result(_make_fake_dl_result(
|
||||||
|
success=True, written_paths=[], files_downloaded=0,
|
||||||
|
))
|
||||||
|
|
||||||
|
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"))
|
||||||
|
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 = (await db.execute(
|
||||||
|
select(Source.backfill_runs_remaining).where(Source.id == source.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert remaining == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_partial_error_type_maps_to_ok_status(
|
||||||
|
db, db_sync, tmp_path, seed_artist_and_source,
|
||||||
|
):
|
||||||
|
"""A non-zero gallery-dl exit accompanied by PARTIAL error_type
|
||||||
|
(real files were downloaded before the run was cut short) is treated
|
||||||
|
as status=ok by the orchestrator — next tick continues."""
|
||||||
|
from backend.app.services.download_service import DownloadService
|
||||||
|
from backend.app.services.gallery_dl import ErrorType
|
||||||
|
from backend.app.services.importer import Importer
|
||||||
|
|
||||||
|
_artist, source = seed_artist_and_source
|
||||||
|
|
||||||
|
images_root = tmp_path / "images"
|
||||||
|
f1 = images_root / "alice" / "patreon" / "post" / "a.jpg"
|
||||||
|
_make_jpg(f1, split="h")
|
||||||
|
|
||||||
|
fake_result = _make_fake_dl_result(
|
||||||
|
success=False, written_paths=[str(f1)], files_downloaded=1,
|
||||||
|
error_type=ErrorType.PARTIAL,
|
||||||
|
error_message="Downloaded 1 file; run did not complete in budget",
|
||||||
|
stdout=f"{f1}\n",
|
||||||
|
)
|
||||||
|
fake_gdl = _fake_gdl_with_result(fake_result)
|
||||||
|
|
||||||
|
sync_settings = db_sync.execute(
|
||||||
|
select(ImportSettings).where(ImportSettings.id == 1)
|
||||||
|
).scalar_one()
|
||||||
|
importer = Importer(
|
||||||
|
session=db_sync, images_root=images_root, import_root=images_root,
|
||||||
|
thumbnailer=Thumbnailer(images_root=images_root), settings=sync_settings,
|
||||||
|
)
|
||||||
|
cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64"))
|
||||||
|
svc = DownloadService(
|
||||||
|
async_session=db, sync_session=db_sync,
|
||||||
|
gdl=fake_gdl, importer=importer, cred_service=cred_service,
|
||||||
|
)
|
||||||
|
event_id = await svc.download_source(source.id)
|
||||||
|
|
||||||
|
ev = (await db.execute(
|
||||||
|
select(DownloadEvent).where(DownloadEvent.id == event_id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert ev.status == "ok"
|
||||||
|
assert ev.error is None
|
||||||
|
# And the source's failure counter isn't bumped — PARTIAL isn't a failure.
|
||||||
|
src_after = (await db.execute(
|
||||||
|
select(Source.consecutive_failures).where(Source.id == source.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert src_after == 0
|
||||||
|
|||||||
@@ -249,3 +249,69 @@ def test_truncate_log_caps_large_text(gdl):
|
|||||||
def test_truncate_log_passes_short_text(gdl):
|
def test_truncate_log_passes_short_text(gdl):
|
||||||
text = "small\n"
|
text = "small\n"
|
||||||
assert gdl._truncate_log(text) == text
|
assert gdl._truncate_log(text) == text
|
||||||
|
|
||||||
|
|
||||||
|
# --- Plan #544: tick/backfill skip emission + PARTIAL classifier ------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_config_emits_tick_skip_value(gdl):
|
||||||
|
"""Tick mode emits gallery-dl's exit:20 to short-circuit catch-up
|
||||||
|
scans once 20 contiguous archived items are seen."""
|
||||||
|
from backend.app.services.gallery_dl import TICK_SKIP_VALUE
|
||||||
|
cfg = gdl._build_config_for_source(
|
||||||
|
platform="patreon",
|
||||||
|
source_config=SourceConfig(),
|
||||||
|
artist_slug="alice",
|
||||||
|
skip_value=TICK_SKIP_VALUE,
|
||||||
|
)
|
||||||
|
assert cfg["extractor"]["skip"] == "exit:20"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_config_emits_backfill_skip_value(gdl):
|
||||||
|
"""Backfill mode keeps gallery-dl's default skip=True so it walks the
|
||||||
|
full post history."""
|
||||||
|
from backend.app.services.gallery_dl import BACKFILL_SKIP_VALUE
|
||||||
|
cfg = gdl._build_config_for_source(
|
||||||
|
platform="patreon",
|
||||||
|
source_config=SourceConfig(),
|
||||||
|
artist_slug="alice",
|
||||||
|
skip_value=BACKFILL_SKIP_VALUE,
|
||||||
|
)
|
||||||
|
assert cfg["extractor"]["skip"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_categorize_partial_when_files_downloaded_then_nonzero_exit(gdl):
|
||||||
|
"""Non-zero exit + ≥1 file downloaded + no source-level error →
|
||||||
|
PARTIAL. Downstream maps to status=ok because the next tick continues."""
|
||||||
|
# stdout has a single downloaded file (line starting with `/`); no
|
||||||
|
# error-class indicators in stderr; non-zero return code.
|
||||||
|
etype, msg = gdl._categorize_error(
|
||||||
|
return_code=1,
|
||||||
|
stdout="/images/alice/patreon/post1/img.jpg\n",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
assert etype == ErrorType.PARTIAL
|
||||||
|
assert "Downloaded 1 file" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_categorize_unknown_when_no_files_and_no_pattern(gdl):
|
||||||
|
"""PARTIAL only kicks in when files were actually written. A non-zero
|
||||||
|
exit with no downloads and no recognized pattern still falls through
|
||||||
|
to UNKNOWN_ERROR — distinct from the partial-success case."""
|
||||||
|
etype, _ = gdl._categorize_error(
|
||||||
|
return_code=2,
|
||||||
|
stdout="",
|
||||||
|
stderr="some unrecognized noise\n",
|
||||||
|
)
|
||||||
|
assert etype == ErrorType.UNKNOWN_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
def test_categorize_tier_limited_wins_over_partial(gdl):
|
||||||
|
"""A run that hit tier-limited warnings AND downloaded some files
|
||||||
|
classifies as TIER_LIMITED (the more specific category), not PARTIAL."""
|
||||||
|
stdout = "/images/alice/patreon/post1/img.jpg\n"
|
||||||
|
stderr = "[patreon][warning] Not allowed to view post 123\n"
|
||||||
|
etype, _ = gdl._categorize_error(
|
||||||
|
return_code=1, stdout=stdout, stderr=stderr,
|
||||||
|
)
|
||||||
|
assert etype == ErrorType.TIER_LIMITED
|
||||||
|
|||||||
@@ -169,3 +169,47 @@ async def test_list_hides_sidecar_synthetic_anchors(db):
|
|||||||
assert {s.url for s in everything} >= {
|
assert {s.url for s in everything} >= {
|
||||||
"https://patreon.com/alice", "sidecar:patreon:alice",
|
"https://patreon.com/alice", "sidecar:patreon:alice",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Plan #544: backfill counter -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_backfill_runs_arms_source(db):
|
||||||
|
"""The service method sets backfill_runs_remaining and returns the
|
||||||
|
updated record so the API can echo it back."""
|
||||||
|
artist = await _artist(db, "Alice")
|
||||||
|
svc = SourceService(db)
|
||||||
|
rec = await svc.create(
|
||||||
|
artist_id=artist.id, platform="patreon",
|
||||||
|
url="https://patreon.com/alice",
|
||||||
|
)
|
||||||
|
assert rec.backfill_runs_remaining == 0
|
||||||
|
updated = await svc.set_backfill_runs(rec.id, 5)
|
||||||
|
assert updated.backfill_runs_remaining == 5
|
||||||
|
|
||||||
|
db_value = (await db.execute(
|
||||||
|
select(Source.backfill_runs_remaining).where(Source.id == rec.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert db_value == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_backfill_runs_rejects_out_of_range(db):
|
||||||
|
artist = await _artist(db, "Alice")
|
||||||
|
svc = SourceService(db)
|
||||||
|
rec = await svc.create(
|
||||||
|
artist_id=artist.id, platform="patreon",
|
||||||
|
url="https://patreon.com/alice",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await svc.set_backfill_runs(rec.id, 0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await svc.set_backfill_runs(rec.id, 11)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_backfill_runs_raises_when_source_missing(db):
|
||||||
|
svc = SourceService(db)
|
||||||
|
with pytest.raises(LookupError):
|
||||||
|
await svc.set_backfill_runs(99999, 3)
|
||||||
|
|||||||
Reference in New Issue
Block a user