diff --git a/src/main/java/ru/mcs/sopds/config/ScannerInitializer.java b/src/main/java/ru/mcs/sopds/config/ScannerInitializer.java new file mode 100644 index 0000000..b9fd4ed --- /dev/null +++ b/src/main/java/ru/mcs/sopds/config/ScannerInitializer.java @@ -0,0 +1,24 @@ +package ru.mcs.sopds.config; + +import ru.mcs.sopds.service.BookScannerService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ScannerInitializer implements ApplicationRunner { + + private final BookScannerService bookScannerService; + + @Override + public void run(ApplicationArguments args) { + log.info("Запуск начального сканирования книг..."); + bookScannerService.scanBooks(); + log.info("Начальное сканирование завершено. Найдено книг: {}", + bookScannerService.getBookCount()); + } +} \ No newline at end of file diff --git a/src/main/java/ru/mcs/sopds/controller/ScannerController.java b/src/main/java/ru/mcs/sopds/controller/ScannerController.java new file mode 100644 index 0000000..e623745 --- /dev/null +++ b/src/main/java/ru/mcs/sopds/controller/ScannerController.java @@ -0,0 +1,60 @@ +package ru.mcs.sopds.controller; + +import ru.mcs.sopds.service.BookScannerService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/scanner") +@RequiredArgsConstructor +public class ScannerController { + + private final BookScannerService bookScannerService; + + @PostMapping("/scan") + public ResponseEntity> scanBooks() { + long startTime = System.currentTimeMillis(); + + bookScannerService.scanBooks(); + long bookCount = bookScannerService.getBookCount(); + + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + + Map response = new HashMap<>(); + response.put("status", "success"); + response.put("message", "Сканирование завершено"); + response.put("bookCount", bookCount); + response.put("durationMs", duration); + + return ResponseEntity.ok(response); + } + + @PostMapping("/cleanup") + public ResponseEntity> cleanupBooks() { + bookScannerService.cleanupDeletedBooks(); + long bookCount = bookScannerService.getBookCount(); + + Map response = new HashMap<>(); + response.put("status", "success"); + response.put("message", "Очистка завершена"); + response.put("bookCount", bookCount); + + return ResponseEntity.ok(response); + } + + @GetMapping("/status") + public ResponseEntity> getStatus() { + long bookCount = bookScannerService.getBookCount(); + + Map response = new HashMap<>(); + response.put("status", "active"); + response.put("bookCount", bookCount); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/ru/mcs/sopds/repository/AuthorRepository.java b/src/main/java/ru/mcs/sopds/repository/AuthorRepository.java index c90f687..f5527be 100644 --- a/src/main/java/ru/mcs/sopds/repository/AuthorRepository.java +++ b/src/main/java/ru/mcs/sopds/repository/AuthorRepository.java @@ -6,7 +6,10 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface AuthorRepository extends JpaRepository { Page findByFullNameStartingWith(String letter, Pageable pageable); + Optional findByFullName(String fullName); } \ No newline at end of file diff --git a/src/main/java/ru/mcs/sopds/repository/BookRepository.java b/src/main/java/ru/mcs/sopds/repository/BookRepository.java index 4e537da..7eb1a6a 100644 --- a/src/main/java/ru/mcs/sopds/repository/BookRepository.java +++ b/src/main/java/ru/mcs/sopds/repository/BookRepository.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface BookRepository extends JpaRepository { @@ -22,4 +23,6 @@ Page findByGenresId(Long genreId, Pageable pageable); Page findByTitleContainingIgnoreCase(String title, Pageable pageable); + + Optional findByFileNameAndFileSize(String fileName, Long fileSize); } \ No newline at end of file diff --git a/src/main/java/ru/mcs/sopds/repository/GenreRepository.java b/src/main/java/ru/mcs/sopds/repository/GenreRepository.java index 00ae267..3768699 100644 --- a/src/main/java/ru/mcs/sopds/repository/GenreRepository.java +++ b/src/main/java/ru/mcs/sopds/repository/GenreRepository.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface GenreRepository extends JpaRepository { @@ -19,4 +20,6 @@ @Query("SELECT g FROM Genre g LEFT JOIN FETCH g.books WHERE g.id = ?1") Genre findByIdWithBooks(Long id); + + Optional findByGenre(String genre); } \ No newline at end of file diff --git a/src/main/java/ru/mcs/sopds/repository/SeriesRepository.java b/src/main/java/ru/mcs/sopds/repository/SeriesRepository.java index e09d5f7..1d652ff 100644 --- a/src/main/java/ru/mcs/sopds/repository/SeriesRepository.java +++ b/src/main/java/ru/mcs/sopds/repository/SeriesRepository.java @@ -4,6 +4,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface SeriesRepository extends JpaRepository { + Optional findBySeries(String series); } \ No newline at end of file diff --git a/src/main/java/ru/mcs/sopds/service/BookScannerService.java b/src/main/java/ru/mcs/sopds/service/BookScannerService.java new file mode 100644 index 0000000..bdb848a --- /dev/null +++ b/src/main/java/ru/mcs/sopds/service/BookScannerService.java @@ -0,0 +1,258 @@ +package ru.mcs.sopds.service; + +import ru.mcs.sopds.entity.*; +import ru.mcs.sopds.repository.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.io.IOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BookScannerService { + + private final BookRepository bookRepository; + private final AuthorRepository authorRepository; + private final GenreRepository genreRepository; + private final SeriesRepository seriesRepository; + + @Value("${sopds.scanner.path:books}") + private String scanPath; + + @Value("${sopds.scanner.enabled:true}") + private boolean scannerEnabled; + + private final Set SUPPORTED_FORMATS = Set.of("fb2", "epub", "pdf", "mobi", "djvu"); + + @Transactional + public void scanBooks() { + if (!scannerEnabled) { + log.info("Сканер отключен в настройках"); + return; + } + + Path booksPath = Paths.get(scanPath); + if (!Files.exists(booksPath)) { + log.warn("Директория для сканирования не существует: {}", booksPath.toAbsolutePath()); + return; + } + + log.info("Начинаем сканирование директории: {}", booksPath.toAbsolutePath()); + + try { + Files.walkFileTree(booksPath, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (isSupportedFormat(file)) { + processBookFile(file); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) { + log.warn("Не удалось обработать файл: {}", file, exc); + return FileVisitResult.CONTINUE; + } + }); + + log.info("Сканирование завершено"); + + } catch (IOException e) { + log.error("Ошибка при сканировании директории", e); + } + } + + private boolean isSupportedFormat(Path file) { + String fileName = file.getFileName().toString().toLowerCase(); + return SUPPORTED_FORMATS.stream().anyMatch(fileName::endsWith); + } + + private void processBookFile(Path filePath) { + try { + String fileName = filePath.getFileName().toString(); + String fileExtension = getFileExtension(fileName).toLowerCase(); + + // Проверяем, не обработан ли уже этот файл + Optional existingBook = bookRepository.findByFileNameAndFileSize( + fileName, Files.size(filePath)); + + if (existingBook.isPresent()) { + log.debug("Книга уже существует в БД: {}", fileName); + return; + } + + Book book = new Book(); + book.setTitle(fileName); // Временное название, будет обновлено при парсинге + book.setFileName(fileName); + book.setPath(filePath.toAbsolutePath().toString()); + book.setFileSize(Files.size(filePath)); + book.setFormat(fileExtension); + book.setLang("ru"); // По умолчанию + + // Получаем дату изменения файла + LocalDateTime fileTime = Files.getLastModifiedTime(filePath) + .toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + book.setTime(fileTime); + + // Парсим метаданные в зависимости от формата + if ("fb2".equals(fileExtension)) { + parseFb2Metadata(filePath, book); + } else { + // Для других форматов используем базовую информацию из имени файла + extractBasicMetadata(fileName, book); + } + + // Сохраняем книгу в БД + bookRepository.save(book); + log.info("Добавлена книга: {}", book.getTitle()); + + } catch (Exception e) { + log.error("Ошибка при обработке файла: {}", filePath, e); + } + } + + private void parseFb2Metadata(Path filePath, Book book) { + try { + Fb2Metadata metadata = Fb2Parser.parse(filePath); + + if (metadata.getTitle() != null && !metadata.getTitle().isEmpty()) { + book.setTitle(metadata.getTitle()); + } + + if (metadata.getAuthors() != null && !metadata.getAuthors().isEmpty()) { + Set authors = metadata.getAuthors().stream() + .map(this::findOrCreateAuthor) + .collect(Collectors.toSet()); + book.setAuthors(authors); + } + + if (metadata.getGenres() != null && !metadata.getGenres().isEmpty()) { + Set genres = metadata.getGenres().stream() + .map(this::findOrCreateGenre) + .collect(Collectors.toSet()); + book.setGenres(genres); + } + + if (metadata.getSeries() != null && !metadata.getSeries().isEmpty()) { + Series series = findOrCreateSeries(metadata.getSeries()); + book.setSeries(series); + } + + if (metadata.getLang() != null && !metadata.getLang().isEmpty()) { + book.setLang(metadata.getLang()); + } + + if (metadata.getDate() != null && !metadata.getDate().isEmpty()) { + book.setDocDate(metadata.getDate()); + } + + } catch (Exception e) { + log.warn("Не удалось распарсить FB2 метаданные для файла: {}", filePath, e); + // Используем базовое извлечение метаданных из имени файла + extractBasicMetadata(filePath.getFileName().toString(), book); + } + } + + private void extractBasicMetadata(String fileName, Book book) { + // Убираем расширение файла + String nameWithoutExt = fileName.replaceFirst("[.][^.]+$", ""); + book.setTitle(nameWithoutExt); + + // Пытаемся извлечь автора из имени файла (формат: "Автор - Название") + String[] parts = nameWithoutExt.split(" - ", 2); + if (parts.length == 2) { + String authorName = parts[0].trim(); + String title = parts[1].trim(); + + book.setTitle(title); + + Author author = findOrCreateAuthor(authorName); + book.setAuthors(Set.of(author)); + } + } + + private Author findOrCreateAuthor(String fullName) { + return authorRepository.findByFullName(fullName) + .orElseGet(() -> { + Author author = new Author(); + author.setFullName(fullName); + // Пытаемся разбить ФИО на компоненты + String[] nameParts = fullName.split(" "); + if (nameParts.length >= 3) { + author.setLastName(nameParts[0]); + author.setFirstName(nameParts[1]); + author.setMiddleName(nameParts[2]); + } else if (nameParts.length == 2) { + author.setLastName(nameParts[0]); + author.setFirstName(nameParts[1]); + } else { + author.setLastName(fullName); + } + return authorRepository.save(author); + }); + } + + private Genre findOrCreateGenre(String genreName) { + return genreRepository.findByGenre(genreName) + .orElseGet(() -> { + Genre genre = new Genre(); + genre.setGenre(genreName); + return genreRepository.save(genre); + }); + } + + private Series findOrCreateSeries(String seriesName) { + return seriesRepository.findBySeries(seriesName) + .orElseGet(() -> { + Series series = new Series(); + series.setSeries(seriesName); + series.setSearchSeries(seriesName.toLowerCase()); + return seriesRepository.save(series); + }); + } + + private String getFileExtension(String fileName) { + int lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex > 0) { + return fileName.substring(lastDotIndex + 1); + } + return ""; + } + + public long getBookCount() { + return bookRepository.count(); + } + + public void cleanupDeletedBooks() { + // Метод для очистки книг, которых нет в файловой системе + List allBooks = bookRepository.findAll(); + int deletedCount = 0; + + for (Book book : allBooks) { + Path bookPath = Paths.get(book.getPath()); + if (!Files.exists(bookPath)) { + bookRepository.delete(book); + deletedCount++; + log.info("Удалена книга из БД (файл отсутствует): {}", book.getTitle()); + } + } + + if (deletedCount > 0) { + log.info("Удалено книг из БД: {}", deletedCount); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/mcs/sopds/service/Fb2Metadata.java b/src/main/java/ru/mcs/sopds/service/Fb2Metadata.java new file mode 100644 index 0000000..abcc46a --- /dev/null +++ b/src/main/java/ru/mcs/sopds/service/Fb2Metadata.java @@ -0,0 +1,17 @@ +package ru.mcs.sopds.service; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class Fb2Metadata { + private String title; + private List authors = new ArrayList<>(); + private List genres = new ArrayList<>(); + private String series; + private String lang; + private String date; + private String publisher; +} \ No newline at end of file diff --git a/src/main/java/ru/mcs/sopds/service/Fb2Parser.java b/src/main/java/ru/mcs/sopds/service/Fb2Parser.java new file mode 100644 index 0000000..b55d3c8 --- /dev/null +++ b/src/main/java/ru/mcs/sopds/service/Fb2Parser.java @@ -0,0 +1,137 @@ +package ru.mcs.sopds.service; + +import lombok.extern.slf4j.Slf4j; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +@Slf4j +public class Fb2Parser { + + public static Fb2Metadata parse(Path filePath) { + Fb2Metadata metadata = new Fb2Metadata(); + + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document document; + + if (filePath.toString().toLowerCase().endsWith(".zip")) { + // FB2 в ZIP архиве + try (ZipFile zipFile = new ZipFile(filePath.toFile())) { + ZipEntry entry = zipFile.entries().nextElement(); // Берем первую запись + try (InputStream is = zipFile.getInputStream(entry)) { + document = builder.parse(is); + } + } + } else { + // Обычный FB2 файл + try (InputStream is = Files.newInputStream(filePath)) { + document = builder.parse(is); + } + } + + document.getDocumentElement().normalize(); + + // Парсим title-info + NodeList titleInfoList = document.getElementsByTagName("title-info"); + if (titleInfoList.getLength() > 0) { + Element titleInfo = (Element) titleInfoList.item(0); + + // Заголовок + NodeList titleList = titleInfo.getElementsByTagName("book-title"); + if (titleList.getLength() > 0) { + metadata.setTitle(titleList.item(0).getTextContent().trim()); + } + + // Авторы + NodeList authorList = titleInfo.getElementsByTagName("author"); + for (int i = 0; i < authorList.getLength(); i++) { + Element authorElement = (Element) authorList.item(i); + String authorName = getAuthorName(authorElement); + if (authorName != null && !authorName.isEmpty()) { + metadata.getAuthors().add(authorName); + } + } + + // Жанры + NodeList genreList = titleInfo.getElementsByTagName("genre"); + for (int i = 0; i < genreList.getLength(); i++) { + String genre = genreList.item(i).getTextContent().trim(); + if (!genre.isEmpty()) { + metadata.getGenres().add(genre); + } + } + + // Серия + NodeList sequenceList = titleInfo.getElementsByTagName("sequence"); + if (sequenceList.getLength() > 0) { + Element sequence = (Element) sequenceList.item(0); + metadata.setSeries(sequence.getAttribute("name")); + } + + // Язык + NodeList langList = titleInfo.getElementsByTagName("lang"); + if (langList.getLength() > 0) { + metadata.setLang(langList.item(0).getTextContent().trim()); + } + } + + // Парсим document-info для даты + NodeList docInfoList = document.getElementsByTagName("document-info"); + if (docInfoList.getLength() > 0) { + Element docInfo = (Element) docInfoList.item(0); + NodeList dateList = docInfo.getElementsByTagName("date"); + if (dateList.getLength() > 0) { + Element dateElement = (Element) dateList.item(0); + metadata.setDate(dateElement.getAttribute("value")); + } + } + + } catch (ParserConfigurationException | SAXException | IOException e) { + log.warn("Ошибка парсинга FB2 файла: {}", filePath, e); + } + + return metadata; + } + + private static String getAuthorName(Element authorElement) { + StringBuilder name = new StringBuilder(); + + NodeList firstNameList = authorElement.getElementsByTagName("first-name"); + if (firstNameList.getLength() > 0) { + name.append(firstNameList.item(0).getTextContent().trim()); + } + + NodeList middleNameList = authorElement.getElementsByTagName("middle-name"); + if (middleNameList.getLength() > 0) { + if (name.length() > 0) name.append(" "); + name.append(middleNameList.item(0).getTextContent().trim()); + } + + NodeList lastNameList = authorElement.getElementsByTagName("last-name"); + if (lastNameList.getLength() > 0) { + if (name.length() > 0) name.append(" "); + name.append(lastNameList.item(0).getTextContent().trim()); + } + + // Если не нашли структурированных полей, ищем любой текст + if (name.length() == 0) { + name.append(authorElement.getTextContent().trim()); + } + + return name.toString(); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fcaa17f..2d2cc6b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -26,6 +26,11 @@ server: port: 8080 +sopds: + scanner: + path: "books" # Путь к папке с книгами относительно рабочей директории + enabled: true # Включить сканер + logging: level: ru.mcs.sopds: DEBUG diff --git a/src/main/resources/templates/authors.html b/src/main/resources/templates/authors.html index c8159d4..1ac6518 100644 --- a/src/main/resources/templates/authors.html +++ b/src/main/resources/templates/authors.html @@ -52,7 +52,7 @@ Имя автора - (0) +