Files
cbwebreader/comic/processing.py
ajurna ce809ed1a9 **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.
2026-10-13 15:58:06 +01:00

119 lines
5.3 KiB
Python

from collections.abc import Iterable
from pathlib import Path
from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.db.models import (
BooleanField,
Case,
Count,
ExpressionWrapper,
F,
IntegerField,
PositiveSmallIntegerField,
Q,
QuerySet,
Value,
When,
)
from django.db.models.functions import Coalesce
from comic import models
from comic.archives import get_archive_files
from comic.errors import NotCompatibleArchive
def reconcile(directory: models.Directory | None = None) -> None:
"""Bring the database in line with the filesystem for one directory.
This is the only place the scan writes, so it runs from the scan task and the
management command - never from a browse request.
"""
dir_path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME
clean_directories(models.Directory.objects.filter(parent=directory), dir_path, directory)
clean_files(models.ComicBook.objects.filter(directory=directory), dir_path, directory)
clean_thumbnails(models.Directory.objects.filter(parent=directory))
clean_thumbnails(models.ComicBook.objects.filter(directory=directory))
def list_directory(user: AbstractBaseUser, directory: models.Directory | None = None) \
-> list[models.Directory | models.ComicBook]:
"""Return the contents of one directory for one user, reading only."""
dir_db_query = models.Directory.objects.filter(parent=directory).annotate(
total=Count('comicbook', distinct=True),
progress=Count('comicbook__comicstatus', Q(comicbook__comicstatus__finished=True,
comicbook__comicstatus__user=user), distinct=True),
finished=ExpressionWrapper(Q(total=F('progress')), output_field=IntegerField()),
unread=ExpressionWrapper(Q(total__gt=F('progress')), output_field=IntegerField()),
)
# A book with no status for this user is simply unread, so the annotations have to
# stand in for the missing row instead of assuming one exists.
file_db_query = models.ComicBook.objects.filter(directory=directory).annotate(
progress=Coalesce(F('comicstatus__last_read_page') + 1, Value(0, output_field=IntegerField())),
finished=Coalesce(F('comicstatus__finished'), Value(False, output_field=BooleanField())),
unread=Coalesce(F('comicstatus__unread'), Value(True, output_field=BooleanField())),
user=F('comicstatus__user'),
classification=Case(
When(directory__isnull=True, then=models.Directory.Classification.C_G),
default=F('directory__classification'),
output_field=PositiveSmallIntegerField(choices=models.Directory.Classification.choices)
)
).filter(Q(user__isnull=True) | Q(user=user.id))
files = list(dir_db_query) + list(file_db_query)
files.sort(key=lambda x: x.title)
files.sort(key=lambda x: x.type, reverse=True)
return files
def clean_directories(directories: QuerySet, dir_path: Path, directory: models.Directory | None = None) -> None:
dir_db_set = {Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories}
dir_list = {x for x in sorted(dir_path.glob('*')) if x.is_dir()}
# Create new directories db instances
for new_directory in dir_list - dir_db_set:
# get_or_create, not save(): another request may have inserted the same directory
# between the query above and this line, which would leave duplicate rows.
models.Directory.objects.get_or_create(name=new_directory.name, parent=directory)
# Remove stale db instances
for stale_directory in dir_db_set - dir_list:
models.Directory.objects.filter(name=stale_directory.name, parent=directory).delete()
def clean_files(files: QuerySet, dir_path: Path, directory: models.Directory | None = None) -> None:
file_list = {x for x in sorted(dir_path.glob('*')) if x.is_file()}
files_db_set = {Path(dir_path, x.file_name) for x in files}
# Parse new comics
books_to_add = []
for new_comic in file_list - files_db_set:
if new_comic.suffix.lower() in settings.SUPPORTED_FILES:
new_book = models.ComicBook(file_name=new_comic.name, directory=directory)
try:
archive, archive_type = new_book.get_archive()
if archive_type == 'archive':
new_book.page_count = len(get_archive_files(archive))
elif archive_type == 'pdf':
new_book.page_count = archive.page_count
except NotCompatibleArchive:
# Unreadable/corrupt archive - index the file with no page count
pass
books_to_add.append(new_book)
# ignore_conflicts: a concurrent scan may have indexed the same file, and
# (directory, file_name) is unique.
models.ComicBook.objects.bulk_create(books_to_add, ignore_conflicts=True)
# Remove stale comic instances
for stale_comic in files_db_set - file_list:
models.ComicBook.objects.filter(file_name=stale_comic.name, directory=directory).delete()
def clean_thumbnails(items: Iterable[models.Directory | models.ComicBook]) -> None:
for item in items:
if item.thumbnail and not Path(item.thumbnail.path).exists():
item.thumbnail.delete()
item.save()