mirror of
https://github.com/ajurna/cbwebreader.git
synced 2026-11-02 10:36:55 +00:00
**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:
1 parent
4d0ba750ad
commit
ce809ed1a9
18 files changed
+364
-81
No files matched your search
@@ -1,8 +1,11 @@
|
||||
"""Tests for the API behaviour of the comic viewsets."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from comic import models
|
||||
from comic.tests.factories import make_cbz
|
||||
|
||||
UNKNOWN = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
@@ -25,6 +28,12 @@ def test_unknown_book_returns_404_for_an_image(client, reader_user) -> None:
|
||||
assert client.get(f'/api/read/{UNKNOWN}/image/1/').status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unknown_book_returns_404_for_the_type_action(client, reader_user) -> None:
|
||||
client.force_login(reader_user)
|
||||
assert client.get(f'/api/read/{UNKNOWN}/type/').status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_image_requires_authentication(client, book) -> None:
|
||||
assert client.get(f'/api/read/{book.selector}/image/1/').status_code == 401
|
||||
@@ -148,3 +157,47 @@ def test_directory_partial_update_rejects_an_unknown_classification(client, admi
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_browse_listing_does_not_write_to_the_database(client, reader_user, comic_volume) -> None:
|
||||
"""Listing is read only, so a browse request cannot race with the scan."""
|
||||
client.force_login(reader_user)
|
||||
target = comic_volume / 'Series'
|
||||
target.mkdir()
|
||||
make_cbz(target / 'issue.cbz', pages=2)
|
||||
|
||||
assert client.get('/api/browse/').status_code == 200
|
||||
assert models.ComicBook.objects.count() == 0
|
||||
assert models.ComicStatus.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scan_endpoint_indexes_files_that_are_only_on_disk(client, reader_user, comic_volume) -> None:
|
||||
client.force_login(reader_user)
|
||||
target = comic_volume / 'Series'
|
||||
target.mkdir()
|
||||
make_cbz(target / 'issue.cbz', pages=2)
|
||||
|
||||
assert [entry['title'] for entry in client.get('/api/browse/').json()] == []
|
||||
|
||||
response = client.post('/api/browse/scan/', data='{}', content_type='application/json')
|
||||
assert response.status_code == 200
|
||||
assert [entry['title'] for entry in response.json()] == ['Series']
|
||||
|
||||
directory = models.Directory.objects.get(name='Series')
|
||||
response = client.post('/api/browse/scan/',
|
||||
data=json.dumps({'selector': str(directory.selector)}),
|
||||
content_type='application/json')
|
||||
assert response.status_code == 200
|
||||
entries = response.json()
|
||||
assert [entry['title'] for entry in entries] == ['issue.cbz']
|
||||
assert entries[0]['type'] == 'ComicBook'
|
||||
assert entries[0]['total'] == 2
|
||||
assert entries[0]['progress'] == 0
|
||||
assert entries[0]['unread'] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scan_endpoint_requires_authentication(client) -> None:
|
||||
assert client.post('/api/browse/scan/', data='{}', content_type='application/json').status_code == 401
|
||||
@@ -3,6 +3,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.db import IntegrityError
|
||||
|
||||
from comic import models
|
||||
from comic.tests.factories import make_cbz, make_pdf
|
||||
@@ -69,3 +70,13 @@ def test_classification_comparison() -> None:
|
||||
allowed_to_read=models.Directory.Classification.C_PG,
|
||||
)
|
||||
assert directory.classification > misc.allowed_to_read
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_duplicate_directory_names_are_rejected() -> None:
|
||||
"""The scan depends on this constraint: without it two concurrent requests can create
|
||||
the same directory twice and the browse listing shows it twice."""
|
||||
parent = models.Directory.objects.create(name='Series')
|
||||
models.Directory.objects.create(name='Volume 1', parent=parent)
|
||||
with pytest.raises(IntegrityError):
|
||||
models.Directory.objects.create(name='Volume 1', parent=parent)
|
||||
@@ -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
|
||||
@@ -23,9 +23,11 @@ def test_scan_indexes_nested_directories(comic_volume: Path, reader_user) -> Non
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scan_without_a_user_stops_before_reading_the_volume(comic_volume: Path) -> None:
|
||||
def test_scan_runs_without_any_user(comic_volume: Path) -> None:
|
||||
"""The command no longer needs a user, because statuses are created when a book is read."""
|
||||
make_cbz(comic_volume / 'issue.cbz', pages=1)
|
||||
|
||||
call_command('scan_comics')
|
||||
|
||||
assert models.ComicBook.objects.count() == 0
|
||||
assert models.ComicBook.objects.count() == 1
|
||||
assert models.ComicStatus.objects.count() == 0
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Tests for the scan task."""
|
||||
|
||||
import pytest
|
||||
|
||||
from comic import models, tasks
|
||||
from comic.tests.factories import make_cbz
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scan_task_runs_through_the_task_backend(comic_volume) -> None:
|
||||
"""The immediate backend executes the task in the caller, so this is the whole path."""
|
||||
make_cbz(comic_volume / 'issue.cbz', pages=2)
|
||||
|
||||
tasks.scan_directory.enqueue()
|
||||
|
||||
assert models.ComicBook.objects.count() == 1
|
||||
assert models.ComicBook.objects.get().page_count == 2
|
||||
Reference in new issue
Block a user