**Refactor directory scanning and add scan task support**

- Added a new `scan_directory` task to enable folder scans as asynchronous tasks while maintaining compatibility with the immediate backend.
- Introduced the `reconcile` function, replacing `generate_directory` for improved separation of concerns between database writes and read operations.
- Established a unique constraint on `Directory` (`one_directory_name_per_parent`) to prevent duplicate directories under the same parent.
- Added a "Scan" button to the frontend for re-reading folders from disk, along with new task invocation endpoints in the API.
- Updated tests across modules to validate the new `scan_directory` task, unique constraints, and database indexing logic.
- Improved cleanup logic for stale files, directories, and thumbnails to handle concurrent operations efficiently.
- Simplified `scan_comics` management command by removing user dependency and aligning it with the new `reconcile` function.
- Enhanced frontend components (`TheComicReader`, `TheComicList`, and others) to include lifecycle method cleanups, prevent memory leaks, and handle task-based scans.
This commit is contained in:
ajurna committed 2026-10-13 15:58:06 +01:00
1 parent 4d0ba750ad
commit ce809ed1a9
18 files changed
+364 -81

No files matched your search

+94 -7
View File
@@ -3,7 +3,7 @@
import pytest
from comic import models
from comic.processing import clean_directories, clean_files, generate_directory
from comic.processing import clean_directories, clean_files, list_directory, reconcile
from comic.tests.factories import make_cbz
@@ -27,22 +27,109 @@ def test_clean_files_ignores_unsupported_extensions(comic_volume, reader_user) -
make_cbz(target / 'issue.cbz', pages=2)
(target / 'notes.txt').write_text('not a comic')
clean_files(models.ComicBook.objects.filter(directory=directory), reader_user, target, directory)
clean_files(models.ComicBook.objects.filter(directory=directory), target, directory)
books = list(models.ComicBook.objects.filter(directory=directory))
assert [book.file_name for book in books] == ['issue.cbz']
assert books[0].page_count == 2
assert models.ComicStatus.objects.filter(user=reader_user).count() == 1
@pytest.mark.django_db
def test_generate_directory_creates_missing_statuses(comic_volume, reader_user) -> None:
def test_reconcile_indexes_the_directory_without_touching_the_database_on_a_second_run(
comic_volume, reader_user) -> None:
directory = models.Directory.objects.create(name='Series')
target = comic_volume / 'Series'
target.mkdir(parents=True)
make_cbz(target / 'issue.cbz', pages=1)
make_cbz(target / 'issue.cbz', pages=2)
entries = generate_directory(reader_user, directory)
reconcile(directory)
first = list(models.ComicBook.objects.filter(directory=directory))
assert [book.file_name for book in first] == ['issue.cbz']
reconcile(directory)
assert list(models.ComicBook.objects.filter(directory=directory)) == first
@pytest.mark.django_db
def test_list_directory_reports_an_unread_book_without_creating_a_status(comic_volume,
reader_user) -> None:
"""Listing is read only: a book nobody has opened has no ComicStatus row at all."""
directory = models.Directory.objects.create(name='Series')
target = comic_volume / 'Series'
target.mkdir(parents=True)
make_cbz(target / 'issue.cbz', pages=3)
reconcile(directory)
entries = list_directory(reader_user, directory)
assert [entry.title for entry in entries] == ['issue.cbz']
assert models.ComicStatus.objects.filter(user=reader_user, comic=entries[0]).exists()
assert entries[0].progress == 0
assert entries[0].unread is True
assert entries[0].finished is False
assert models.ComicStatus.objects.count() == 0
@pytest.mark.django_db
def test_list_directory_shows_progress_for_a_book_that_has_been_read(comic_volume,
reader_user) -> None:
directory = models.Directory.objects.create(name='Series')
target = comic_volume / 'Series'
target.mkdir(parents=True)
make_cbz(target / 'issue.cbz', pages=3)
reconcile(directory)
book = models.ComicBook.objects.get(directory=directory)
models.ComicStatus.objects.create(user=reader_user, comic=book, last_read_page=1, unread=False)
entries = list_directory(reader_user, directory)
assert entries[0].progress == 2
assert entries[0].unread is False
assert entries[0].finished is False
@pytest.mark.django_db
def test_clean_files_survives_a_concurrent_scan_of_the_same_directory(comic_volume, reader_user,
rated_directory) -> None:
"""Two requests can build the same 'new file' list; the second insert must not clash."""
target = comic_volume / rated_directory.name
target.mkdir(parents=True, exist_ok=True)
make_cbz(target / 'issue.cbz', pages=2)
stale = models.ComicBook.objects.filter(directory=rated_directory)
list(stale) # force evaluation so the cached snapshot is empty
models.ComicBook.objects.create(file_name='issue.cbz', directory=rated_directory, page_count=2)
clean_files(stale, target, rated_directory)
assert models.ComicBook.objects.filter(directory=rated_directory).count() == 1
@pytest.mark.django_db
def test_clean_files_ignores_rows_another_request_already_removed(comic_volume, reader_user,
rated_directory) -> None:
target = comic_volume / rated_directory.name
target.mkdir(parents=True, exist_ok=True)
stale_book = models.ComicBook.objects.create(file_name='gone.cbz', directory=rated_directory)
stale = models.ComicBook.objects.filter(directory=rated_directory)
list(stale) # cache the row, then let another request delete it
stale_book.delete()
clean_files(stale, target, rated_directory)
assert models.ComicBook.objects.filter(directory=rated_directory).count() == 0
@pytest.mark.django_db
def test_clean_directories_does_not_duplicate_rows_when_another_request_inserts_first(comic_volume,
reader_user) -> None:
parent = models.Directory.objects.create(name='Series')
(comic_volume / 'Series' / 'New').mkdir(parents=True)
stale = models.Directory.objects.filter(parent=parent)
list(stale) # cached snapshot is empty
models.Directory.objects.create(name='New', parent=parent)
clean_directories(stale, comic_volume / 'Series', parent)
assert models.Directory.objects.filter(parent=parent).count() == 1