CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 19s
CI and images / frontend-build (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 34s
CI and images / integration (push) Successful in 2m18s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m50s
CI and images / smoke-web (push) Successful in 49s
CI and images / promote (push) Successful in 2s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
A download writes its files into the artist's folder and imports them in a later step. A run that stopped in between left complete files no record points at, and a walk that had already marked them seen never returned for them; on the live instance that is about 95 GB of art. "Import files the library is missing" (Cleanup, Duplicates & leftovers) walks every folder named for an artist, matched by slug so a differently cased folder still matches, and lists the images, videos and archives no image, attachment or queued import points at. Stores, sidecars, staging files and files changed in the last day are left alone. Preview shows the counts per artist; Import queues the same list as a library import batch, whose tasks file the folder's post record, attach each file where it lies with its folder's artist and source, and remove the ones the library already holds or the import filters reject. A file whose import crashed is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
488 lines
21 KiB
Python
488 lines
21 KiB
Python
"""FC-3k: admin destructive Celery tasks.
|
|
|
|
Two long-running ops on the maintenance queue. task_run lifecycle is
|
|
captured automatically by FC-3i signals — these tasks just return
|
|
their summary dict so it lands in task_run.metadata (via Celery's
|
|
result backend) for the dashboard to surface.
|
|
|
|
Soft/hard time limits inherit the FC-3i recovery sweep: a runaway
|
|
task gets killed and flipped to status='timeout' by
|
|
recover_stalled_task_runs.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.exc import DBAPIError, OperationalError
|
|
|
|
from ..celery_app import celery
|
|
from ..models import ImageRecord
|
|
from ..services import cleanup_service
|
|
from ._sync_engine import sync_session_factory as _sync_session_factory
|
|
|
|
log = logging.getLogger(__name__)
|
|
IMAGES_ROOT = Path("/images")
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.admin.delete_artist_cascade_task",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError),
|
|
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
|
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
|
|
)
|
|
def delete_artist_cascade_task(self, *, artist_id: int) -> dict:
|
|
"""Wraps cleanup_service.delete_artist_cascade. Returns the
|
|
service's summary dict for FC-3i task_run.metadata capture."""
|
|
SessionLocal = _sync_session_factory()
|
|
with SessionLocal() as session:
|
|
return cleanup_service.delete_artist_cascade(
|
|
session, artist_id=artist_id, images_root=IMAGES_ROOT,
|
|
)
|
|
|
|
|
|
# Orphan repair (#859). Safety guard: an NFS/filesystem stall makes EVERY file
|
|
# look missing — never delete records en masse on that basis. If a non-trivial
|
|
# sample comes back mostly missing, ABORT without deleting (assume the FS is
|
|
# unhealthy, not that the library evaporated). Operator-triggered ONLY — NOT a
|
|
# periodic sweep, precisely to avoid an unattended run firing during an NFS blip.
|
|
_ORPHAN_MIN_SAMPLE = 50
|
|
_ORPHAN_MAX_MISSING_FRAC = 0.10
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.admin.prune_missing_file_records_task",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError),
|
|
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
|
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
|
|
)
|
|
def prune_missing_file_records_task(self) -> dict:
|
|
"""Delete ImageRecords whose backing file is gone from disk (orphans — e.g.
|
|
the external-attach unlink bug #859). Every FK to image_record is CASCADE /
|
|
SET NULL, so a Core DELETE cleans provenance, series pages, predictions and
|
|
tag links; leftover thumbnails are unlinked best-effort. Returns a summary.
|
|
|
|
Aborts WITHOUT deleting if a non-trivial sample is mostly missing (a
|
|
filesystem/NFS stall, not real orphans) — see the guard constants above.
|
|
"""
|
|
SessionLocal = _sync_session_factory()
|
|
checked = 0
|
|
missing_ids: list[int] = []
|
|
thumbs: list[str] = []
|
|
last_id = 0
|
|
with SessionLocal() as session:
|
|
while True:
|
|
rows = session.execute(
|
|
select(ImageRecord.id, ImageRecord.path, ImageRecord.thumbnail_path)
|
|
.where(ImageRecord.id > last_id)
|
|
.order_by(ImageRecord.id)
|
|
.limit(1000)
|
|
).all()
|
|
if not rows:
|
|
break
|
|
for rid, path, thumb in rows:
|
|
last_id = rid
|
|
checked += 1
|
|
if not (IMAGES_ROOT / path).exists():
|
|
missing_ids.append(rid)
|
|
if thumb:
|
|
thumbs.append(thumb)
|
|
|
|
if not missing_ids:
|
|
return {"checked": checked, "missing": 0, "deleted": 0}
|
|
|
|
frac = len(missing_ids) / checked if checked else 0.0
|
|
if checked >= _ORPHAN_MIN_SAMPLE and frac > _ORPHAN_MAX_MISSING_FRAC:
|
|
log.warning(
|
|
"orphan-repair ABORTED: %d/%d (%.0f%%) records missing on disk — "
|
|
"likely a filesystem/NFS problem, not real orphans. No deletions.",
|
|
len(missing_ids), checked, frac * 100,
|
|
)
|
|
return {
|
|
"checked": checked, "missing": len(missing_ids), "deleted": 0,
|
|
"aborted": "too many missing — filesystem problem suspected",
|
|
}
|
|
|
|
deleted = 0
|
|
for i in range(0, len(missing_ids), 500): # keep well under psycopg's param ceiling
|
|
chunk = missing_ids[i:i + 500]
|
|
session.execute(delete(ImageRecord).where(ImageRecord.id.in_(chunk)))
|
|
deleted += len(chunk)
|
|
session.commit()
|
|
|
|
for t in thumbs: # cosmetic — outside the txn, never fail the repair on these
|
|
try:
|
|
(IMAGES_ROOT / t).unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
log.info("orphan-repair: checked=%d missing=%d deleted=%d", checked, len(missing_ids), deleted)
|
|
return {"checked": checked, "missing": len(missing_ids), "deleted": deleted}
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.admin.repair_discord_downloads_task",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError),
|
|
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
|
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
|
|
)
|
|
def repair_discord_downloads_task(self, dry_run: bool = True) -> dict:
|
|
"""Clean re-download of the Discord files broken by the `None` naming
|
|
(#3999). dry_run (the default) returns the projection; apply deletes the
|
|
broken images and their files, makes gallery-dl forget every Discord
|
|
download, and restarts every Discord source's backfill. Defaults to the SAFE
|
|
preview because the apply deletes files. Operator-triggered only."""
|
|
from ..services.discord_repair import repair_discord_downloads
|
|
|
|
SessionLocal = _sync_session_factory()
|
|
with SessionLocal() as session:
|
|
return repair_discord_downloads(
|
|
session, images_root=IMAGES_ROOT, dry_run=dry_run,
|
|
)
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.admin.dedup_videos_task",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError),
|
|
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
|
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
|
|
)
|
|
def dedup_videos_task(self, dry_run: bool = False) -> dict:
|
|
"""Tier-1 video dedup (#871): re-probe NULL-duration videos, cluster by
|
|
artist + duration + aspect, keep the highest-res copy per cluster. dry_run
|
|
returns the projection (groups/redundant/reclaimable bytes) WITHOUT deleting;
|
|
apply re-points each loser's post links to the keeper then deletes the
|
|
redundant records + files. Operator-triggered; the summary lands in
|
|
task_run.metadata (FC-3i) for the Maintenance card to surface."""
|
|
SessionLocal = _sync_session_factory()
|
|
with SessionLocal() as session:
|
|
return cleanup_service.dedup_videos(
|
|
session, images_root=IMAGES_ROOT, dry_run=dry_run,
|
|
)
|
|
|
|
|
|
# Wall-clock budget for the gated-preview re-walk: stop walking new sources past
|
|
# this and report partial (operator re-runs to finish). Sits under the soft limit
|
|
# so the task returns its summary cleanly instead of being SIGKILLed mid-walk.
|
|
_GATED_PURGE_BUDGET_SECONDS = 1500
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.admin.purge_gated_previews_task",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError),
|
|
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
|
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
|
|
)
|
|
def purge_gated_previews_task(self, dry_run: bool = True) -> dict:
|
|
"""Cleanup (#874 follow-up): purge blurred locked-preview images grabbed from
|
|
tier-gated Patreon posts before the ingester fix. Re-walks every enabled
|
|
Patreon source (read-only, no downloads) to re-derive which posts are gated NOW
|
|
and the blurred filehashes Patreon serves for them, then matches by content
|
|
hash and (unless dry_run) deletes only those exact files — real content
|
|
downloaded when access existed has a different hash and is provably spared.
|
|
|
|
dry_run=True returns the projection (gated posts / matched images / reclaimable
|
|
bytes / unverifiable kept) WITHOUT deleting; dry_run=false applies it. The
|
|
summary lands in task_run.metadata (FC-3i) for the Maintenance card. Time-boxed
|
|
across sources: a `partial` result means re-run to finish (idempotent — a
|
|
re-walk re-derives the same matches and already-deleted files stay deleted)."""
|
|
import asyncio
|
|
import time as _time
|
|
|
|
from sqlalchemy import select as _select
|
|
|
|
from ..models import Source
|
|
from ..services.credential_crypto import CredentialCrypto
|
|
from ..services.credential_service import CredentialService
|
|
from ..services.patreon_client import PatreonClient
|
|
from ..services.patreon_resolver import resolve_campaign_id_for_source
|
|
from ._async_session import async_session_factory
|
|
|
|
key_path = IMAGES_ROOT / "secrets" / "credential_key.b64"
|
|
|
|
async def _run() -> dict:
|
|
async_factory, async_engine = async_session_factory()
|
|
SessionLocal = _sync_session_factory()
|
|
try:
|
|
async with async_factory() as async_session:
|
|
cred = CredentialService(async_session, CredentialCrypto(key_path))
|
|
cookies_obj = await cred.get_cookies_path("patreon")
|
|
cookies_path = str(cookies_obj) if cookies_obj else None
|
|
|
|
with SessionLocal() as session:
|
|
sources = session.execute(
|
|
_select(Source.id, Source.url, Source.config_overrides)
|
|
.where(Source.platform == "patreon", Source.enabled.is_(True))
|
|
).all()
|
|
|
|
gated_map: dict[int, dict] = {}
|
|
scanned = 0
|
|
partial = False
|
|
loop = asyncio.get_running_loop()
|
|
start = _time.monotonic()
|
|
for sid, url, overrides in sources:
|
|
if _time.monotonic() - start >= _GATED_PURGE_BUDGET_SECONDS:
|
|
partial = True
|
|
break
|
|
campaign_id, _resolved = await resolve_campaign_id_for_source(
|
|
url, cookies_path, overrides or {}
|
|
)
|
|
if not campaign_id:
|
|
log.warning(
|
|
"gated-purge: couldn't resolve campaign for source %s (%s) "
|
|
"— skipping", sid, url,
|
|
)
|
|
continue
|
|
client = PatreonClient(cookies_path)
|
|
try:
|
|
gated = await loop.run_in_executor(
|
|
None, cleanup_service.collect_gated_previews, client, campaign_id
|
|
)
|
|
except Exception as exc: # one bad feed must not strand the sweep
|
|
log.warning("gated-purge walk failed for source %s: %s", sid, exc)
|
|
continue
|
|
scanned += 1
|
|
if gated:
|
|
gated_map[int(sid)] = gated
|
|
|
|
with SessionLocal() as session:
|
|
summary = cleanup_service.purge_gated_previews(
|
|
session, gated_map=gated_map, images_root=IMAGES_ROOT,
|
|
dry_run=dry_run,
|
|
)
|
|
summary["sources_scanned"] = scanned
|
|
summary["sources_total"] = len(sources)
|
|
summary["partial"] = partial
|
|
return summary
|
|
finally:
|
|
await async_engine.dispose()
|
|
|
|
return asyncio.run(_run())
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.admin.bulk_delete_images_task",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError),
|
|
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
|
soft_time_limit=900, time_limit=1200, # 15 min / 20 min
|
|
)
|
|
def bulk_delete_images_task(self, *, image_ids: list[int]) -> dict:
|
|
"""Wraps cleanup_service.delete_images."""
|
|
SessionLocal = _sync_session_factory()
|
|
with SessionLocal() as session:
|
|
return cleanup_service.delete_images(
|
|
session, image_ids=image_ids, images_root=IMAGES_ROOT,
|
|
)
|
|
|
|
|
|
# Time-box one chunk well under the soft limit so a large archive back-catalog
|
|
# can't run the task into the Celery time limit (or hog the maintenance_long
|
|
# lane). The task re-enqueues itself with the resume cursor until the scan is
|
|
# exhausted — mirrors normalize_tags_task (operator-asked 2026-06-07: reasonable
|
|
# timeout, then re-queue so other work keeps flowing).
|
|
_REEXTRACT_CHUNK_SECONDS = 600
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.admin.reextract_archive_attachments_task",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError),
|
|
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
|
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
|
|
)
|
|
def reextract_archive_attachments_task(self, after_id: int = 0) -> dict:
|
|
"""Wraps cleanup_service.reextract_archive_attachments (#713 part 2):
|
|
re-extract PostAttachments that are actually archives but were filed
|
|
opaquely before the magic-byte gate, and link their members to the post.
|
|
|
|
Time-boxed + self-resuming: scans attachments after ``after_id`` and, on a
|
|
chunk cut, re-enqueues from where it stopped so a big backlog finishes across
|
|
chunks instead of dying at the soft limit."""
|
|
SessionLocal = _sync_session_factory()
|
|
with SessionLocal() as session:
|
|
summary = cleanup_service.reextract_archive_attachments(
|
|
session, images_root=IMAGES_ROOT,
|
|
time_budget_seconds=_REEXTRACT_CHUNK_SECONDS, after_id=after_id,
|
|
)
|
|
# More attachments past this chunk's cursor — continue in the next.
|
|
if summary.get("partial") and summary.get("resume_after_id", 0) > after_id:
|
|
log.info(
|
|
"reextract chunk done (%d scanned, %d archives, resume after id %s) "
|
|
"— re-enqueuing to continue",
|
|
summary.get("scanned", 0), summary.get("archives", 0),
|
|
summary["resume_after_id"],
|
|
)
|
|
reextract_archive_attachments_task.delay(summary["resume_after_id"])
|
|
return summary
|
|
|
|
|
|
# Time-box one chunk well under the soft limit so a large back-catalog (the
|
|
# first run recases the whole booru vocabulary) can't run the task into the
|
|
# Celery time limit — it timed out at 40 min, operator-flagged 2026-06-07. The
|
|
# task re-enqueues itself until nothing remains (idempotent — already-canonical
|
|
# groups are skipped). 600s keeps each chunk short enough that the recovery
|
|
# sweep and other maintenance tasks interleave on the concurrency-1 queue.
|
|
_NORMALIZE_CHUNK_SECONDS = 600
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.admin.normalize_tags_task",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError),
|
|
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
|
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
|
|
)
|
|
def normalize_tags_task(self) -> dict:
|
|
"""Wraps tag_service.normalize_existing_tags (#714): Title-Case the
|
|
back-catalog and merge case/whitespace-variant duplicate tags via the
|
|
tested async merge path. Time-boxed + self-resuming so a huge first run
|
|
finishes across chunks instead of timing out. Runs under its own asyncio
|
|
loop + per-task async engine (NullPool), mirroring download_source."""
|
|
import asyncio
|
|
|
|
from ..services.tag_service import normalize_existing_tags
|
|
from ._async_session import async_session_factory
|
|
|
|
async def _run() -> dict:
|
|
# lock_timeout=30s: a per-group merge repoints FKs across image_tag and
|
|
# series_page; if a statement blocks on a lock (e.g. behind a schema
|
|
# migration holding ACCESS EXCLUSIVE on series_page — the exact wedge that
|
|
# made this task run to the 40-min hard limit with no progress,
|
|
# operator-flagged 2026-06-07), it now fails fast. The per-group handler
|
|
# catches it (rollback + error++) and the loop continues, so one blocked
|
|
# group can't strand the whole chunk.
|
|
async_factory, async_engine = async_session_factory(
|
|
server_settings={"lock_timeout": "30s"}
|
|
)
|
|
try:
|
|
async with async_factory() as session:
|
|
# normalize_existing_tags commits per group internally.
|
|
return await normalize_existing_tags(
|
|
session, dry_run=False,
|
|
time_budget_seconds=_NORMALIZE_CHUNK_SECONDS,
|
|
)
|
|
finally:
|
|
await async_engine.dispose()
|
|
|
|
summary = asyncio.run(_run())
|
|
# More groups to canonicalize than fit this chunk — continue in the next.
|
|
if summary.get("partial") and summary.get("remaining", 0) > 0:
|
|
log.info(
|
|
"normalize_tags_task chunk done (%d processed, %d remaining) — "
|
|
"re-enqueuing to continue",
|
|
summary.get("groups_processed", 0), summary["remaining"],
|
|
)
|
|
normalize_tags_task.delay()
|
|
return summary
|
|
|
|
|
|
# Time-box one rescan chunk well under the soft limit and re-enqueue from the
|
|
# cursor — scoring every post against its artist's series is O(posts) and grows
|
|
# with the library (FC-6.3). Mirrors normalize_tags_task.
|
|
_SERIES_RESCAN_CHUNK_SECONDS = 600
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.admin.rescan_series_suggestions_task",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError),
|
|
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
|
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
|
|
)
|
|
def rescan_series_suggestions_task(self, after_post_id: int = 0) -> dict:
|
|
"""Score posts against their artist's series and write pending suggestions
|
|
(FC-6.3). Settings-gated; time-boxed + self-resuming from a post-id cursor.
|
|
Per-task async engine (NullPool) under its own asyncio loop, like normalize."""
|
|
import asyncio
|
|
|
|
from ..models import ImportSettings
|
|
from ..services.series_match_service import SeriesMatchService
|
|
from ._async_session import async_session_factory
|
|
|
|
async def _run() -> dict:
|
|
async_factory, async_engine = async_session_factory()
|
|
try:
|
|
async with async_factory() as session:
|
|
settings = await ImportSettings.load(session)
|
|
if not settings.series_suggest_enabled:
|
|
return {"skipped": "series suggestions disabled"}
|
|
threshold = settings.series_suggest_threshold
|
|
return await SeriesMatchService(session).rescan(
|
|
threshold=threshold,
|
|
time_budget_seconds=_SERIES_RESCAN_CHUNK_SECONDS,
|
|
after_post_id=after_post_id,
|
|
)
|
|
finally:
|
|
await async_engine.dispose()
|
|
|
|
summary = asyncio.run(_run())
|
|
if summary.get("partial") and summary.get("resume_after_id", 0) > after_post_id:
|
|
log.info(
|
|
"rescan_series_suggestions chunk done (%d scanned, %d suggested, "
|
|
"resume after %s) — re-enqueuing",
|
|
summary.get("scanned", 0), summary.get("suggested", 0),
|
|
summary["resume_after_id"],
|
|
)
|
|
rescan_series_suggestions_task.delay(summary["resume_after_id"])
|
|
return summary
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.admin.reclaim_orphaned_attachments_task",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError),
|
|
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
|
# The service stops walking at its own 900s budget and reports partial, so
|
|
# these limits are the backstop for a wedged filesystem (NFS stall), not the
|
|
# expected exit. Comfortably above the budget so a normal run always returns
|
|
# its summary rather than being killed mid-walk.
|
|
soft_time_limit=1200, time_limit=1500, # 20 min / 25 min
|
|
)
|
|
def reclaim_orphaned_attachments_task(self, dry_run: bool = True) -> dict:
|
|
"""Reclaim unattributed PostAttachment rows and the store blobs nothing
|
|
references any more (#3068). dry_run (the default) returns the projection
|
|
without touching rows or files; apply deletes the orphan rows, then unlinks
|
|
every blob no surviving row references.
|
|
|
|
Defaults to the SAFE preview — unlike the other tasks here, whose apply is
|
|
reversible-ish or scoped; this one deletes files. Operator-triggered only,
|
|
never on a beat: an unattended sweep that unlinks blobs is not something to
|
|
run without someone reading the projection first."""
|
|
SessionLocal = _sync_session_factory()
|
|
with SessionLocal() as session:
|
|
return cleanup_service.reclaim_orphaned_attachments(
|
|
session, images_root=IMAGES_ROOT, dry_run=dry_run,
|
|
)
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.admin.import_unimported_files_task",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError),
|
|
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
|
# One walk of the artists' folders: a stat per file, so minutes on a large
|
|
# library on network storage. The limits are the backstop for a wedged
|
|
# filesystem, not the expected exit.
|
|
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
|
|
)
|
|
def import_unimported_files_task(self, dry_run: bool = True) -> dict:
|
|
"""Find files in the artists' folders that the library has no record of.
|
|
dry_run (the default) returns what was found, per artist, without changing
|
|
anything; apply queues them as a `library` import batch, which attaches each
|
|
file where it lies and removes the ones the library already holds or the
|
|
import filters reject."""
|
|
from ..services import library_scan
|
|
|
|
SessionLocal = _sync_session_factory()
|
|
with SessionLocal() as session:
|
|
if dry_run:
|
|
_found, summary = library_scan.find_unimported(session, IMAGES_ROOT)
|
|
return {**summary, "batch_id": None}
|
|
return library_scan.queue_unimported(session, IMAGES_ROOT)
|