mirror of
https://github.com/ajurna/cbwebreader.git
synced 2026-11-02 10:36:55 +00:00
- 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.
119 lines
5.3 KiB
Python
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()
|