feat: Cleanup can import files that are on disk but not in the library
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
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>
This commit is contained in:
@@ -708,3 +708,22 @@ async def test_trigger_reclaim_attachments_threads_apply(client, monkeypatch):
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
assert calls[0][1] == {"dry_run": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_import_unimported_defaults_to_preview(client, monkeypatch):
|
||||
"""An empty body previews: the apply removes files the library rejects."""
|
||||
from backend.app.tasks import admin as admin_tasks
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
admin_tasks.import_unimported_files_task, "delay", _fake_delay(calls)
|
||||
)
|
||||
resp = await client.post("/api/admin/maintenance/import-unimported", json={})
|
||||
assert resp.status_code == 202
|
||||
assert calls[0][1] == {"dry_run": True}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/admin/maintenance/import-unimported", json={"dry_run": False},
|
||||
)
|
||||
assert calls[1][1] == {"dry_run": False}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Finding files in the artists' folders that the library has no record of, and
|
||||
importing them where they lie (services.library_scan + the `library` batch)."""
|
||||
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
import backend.app.tasks.import_file as import_file
|
||||
from backend.app.models import Artist, ImageRecord, ImportSettings, ImportTask
|
||||
from backend.app.services import library_scan
|
||||
from backend.app.services.importer import Importer
|
||||
from backend.app.services.thumbnailer import Thumbnailer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _jpeg(seed: int) -> bytes:
|
||||
im = Image.new("RGB", (256, 256))
|
||||
px = im.load()
|
||||
for y in range(256):
|
||||
for x in range(256):
|
||||
px[x, y] = ((x * seed) % 256, (y * (seed + 1)) % 256, (x ^ y) % 256)
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, "JPEG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _write(path, data: bytes, *, age_days: float = 2):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
t = time.time() - age_days * 86400
|
||||
os.utime(path, (t, t))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def layout(db_sync, tmp_path):
|
||||
db_sync.add(Artist(name="Libscan Maewix", slug="libscan-maewix"))
|
||||
db_sync.commit()
|
||||
post = tmp_path / "Libscan-Maewix" / "patreon" / "2026-05-01_123_Pack"
|
||||
files = {
|
||||
"missing": _write(post / "01_art.jpg", _jpeg(1)),
|
||||
"owned": _write(post / "02_owned.jpg", _jpeg(2)),
|
||||
"sidecar": _write(post / "01_art.json", b'{"category": "patreon", "id": "123"}'),
|
||||
"staging": _write(post / "03_big.zip.part", b"partial"),
|
||||
"recent": _write(post / "04_new.jpg", _jpeg(4), age_days=0),
|
||||
"not_media": _write(post / "05_game.exe", b"MZ"),
|
||||
"no_artist": _write(tmp_path / "libscan-nobody" / "x" / "a.jpg", _jpeg(5)),
|
||||
"store": _write(tmp_path / "thumbs" / "abc" / "f.jpg", _jpeg(6)),
|
||||
}
|
||||
db_sync.add(ImageRecord(
|
||||
path=str(files["owned"]), sha256="libscan-owned".ljust(64, "x"),
|
||||
size_bytes=files["owned"].stat().st_size, mime="image/jpeg",
|
||||
width=256, height=256, origin="downloaded", integrity_status="ok",
|
||||
))
|
||||
db_sync.commit()
|
||||
return tmp_path, files
|
||||
|
||||
|
||||
def test_finds_only_unrecorded_media_in_an_artists_folder(db_sync, layout):
|
||||
"""A differently-cased folder still matches its artist. Recorded files,
|
||||
sidecars, staging files, the library's own stores, folders that match no
|
||||
artist, files changed in the last day and non-media are not listed."""
|
||||
root, files = layout
|
||||
found, summary = library_scan.find_unimported(db_sync, root)
|
||||
|
||||
assert [u.path for u in found] == [files["missing"]]
|
||||
assert found[0].artist_slug == "libscan-maewix"
|
||||
assert found[0].task_type == "media"
|
||||
assert summary["files"] == 1
|
||||
assert summary["recent"] == 1
|
||||
assert summary["not_media"] == 1
|
||||
assert summary["no_artist"] == 1
|
||||
assert summary["artists"][0]["slug"] == "libscan-maewix"
|
||||
assert summary["artists"][0]["samples"] == [
|
||||
"Libscan-Maewix/patreon/2026-05-01_123_Pack/01_art.jpg",
|
||||
]
|
||||
|
||||
|
||||
def test_queue_makes_a_library_batch_of_what_find_lists(db_sync, layout, monkeypatch):
|
||||
"""The import queued is exactly the previewed list."""
|
||||
root, files = layout
|
||||
queued = []
|
||||
monkeypatch.setattr(import_file, "enqueue_import", lambda tid, tt: queued.append((tid, tt)))
|
||||
|
||||
summary = library_scan.queue_unimported(db_sync, root)
|
||||
|
||||
tasks = db_sync.execute(
|
||||
select(ImportTask).where(ImportTask.batch_id == summary["batch_id"])
|
||||
).scalars().all()
|
||||
assert [t.source_path for t in tasks] == [str(files["missing"])]
|
||||
assert tasks[0].batch.scan_mode == "library"
|
||||
assert queued == [(tasks[0].id, "media")]
|
||||
|
||||
|
||||
def test_a_library_import_attaches_in_place_and_removes_a_copy(db_sync, layout, monkeypatch):
|
||||
"""The file stays where it is and becomes the record's file. A byte-identical
|
||||
copy found later is removed from disk, since the library already holds it."""
|
||||
root, files = layout
|
||||
monkeypatch.setattr(import_file, "IMAGES_ROOT", root)
|
||||
importer = Importer(
|
||||
session=db_sync, images_root=root, import_root=root,
|
||||
thumbnailer=Thumbnailer(images_root=root),
|
||||
settings=ImportSettings.load_sync(db_sync),
|
||||
)
|
||||
|
||||
result = import_file._attach_where_it_lies(db_sync, importer, files["missing"])
|
||||
|
||||
assert result.status == "imported"
|
||||
record = db_sync.get(ImageRecord, result.image_id)
|
||||
assert record.path == str(files["missing"])
|
||||
assert files["missing"].exists()
|
||||
assert db_sync.get(Artist, record.artist_id).slug == "libscan-maewix"
|
||||
|
||||
copy = _write(files["missing"].parent / "09_copy.jpg", files["missing"].read_bytes())
|
||||
again = import_file._attach_where_it_lies(db_sync, importer, copy)
|
||||
assert again.status == "skipped"
|
||||
assert not copy.exists()
|
||||
assert files["missing"].exists()
|
||||
@@ -889,3 +889,14 @@ def test_reclaim_attachments_stuck_threshold_exceeds_hard_time_limit():
|
||||
"backend.app.tasks.admin.reclaim_orphaned_attachments_task"
|
||||
]
|
||||
assert override >= hard_minutes
|
||||
|
||||
|
||||
def test_import_unimported_stuck_threshold_exceeds_hard_time_limit():
|
||||
"""The same invariant for the walk that imports files on disk."""
|
||||
from backend.app.tasks.admin import import_unimported_files_task
|
||||
from backend.app.tasks.maintenance import TASK_STUCK_THRESHOLD_MINUTES
|
||||
|
||||
override = TASK_STUCK_THRESHOLD_MINUTES[
|
||||
"backend.app.tasks.admin.import_unimported_files_task"
|
||||
]
|
||||
assert override >= import_unimported_files_task.time_limit / 60
|
||||
|
||||
Reference in New Issue
Block a user