Feat/komga api (#2071)

* Implement Komga API endpoints with OPDS authentication

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* Add database migration and documentation for Komga API

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* Address code review comments - improve performance and maintainability

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* chore: update with main develop

* chore: log cleanup

* chore: fixed switch with missing types

* chore: missing case

* Merge pull request #4 from farfromrefug/copilot/fix-500-error-on-books-api

Fix NPE in Komga books API when pageCount is null and add unpaged parameter

* Add collections endpoint and page download with PNG conversion support

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* Address code review feedback for better resource management and error messages

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* Fix convert parameter to match specification (convert=png)

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* chore: renamed migration

* chore: migration

* chore: migration fix

* chore: should work now

* chore: settings

* chore: working with mihon

* Initial plan

* Add clean query parameter for Komga API endpoints

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* Address code review comments - remove unused imports and add @Primary annotation

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* Add demo test to illustrate clean mode effectiveness

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* Support both ?clean and ?clean=true syntax

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* Filter out empty arrays in clean mode

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* chore: missing field

* chore: missing field

* chore: fix error with missing number

* fix: added groupUnknown API parameters to sort by "Unknown Series" (true by default)

* Initial plan

* Convert groupUnknown from query parameter to Booklore setting

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* Remove unused groupUnknown variables from service and mapper

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* chore: fix seriesTitle in ungrouped unknowns

* Initial plan

* Optimize Komga API performance for series listing

- Optimize getAllSeries to only convert series on current page to DTOs
- Optimize getBooksBySeries to fetch books only once (not twice)
- Add database query methods for future optimizations
- Update tests to work with new optimizations
- All existing tests pass

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* Clean up unused database query methods in BookRepository

Remove unused optimization queries that don't align with application-level series grouping logic

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* Optimize getAllSeries to query distinct series names from database

- Add database queries to fetch distinct series names directly (no need to load all books)
- Add queries to fetch books only for specific series (when building DTOs)
- Support both groupUnknown=true and groupUnknown=false modes
- Add test to verify optimization works and books aren't loaded unnecessarily
- Performance improvement: For 1000+ books grouped into 100+ series, now only queries series names (~100 rows) instead of loading all books (~1000+ rows), then loads books only for the current page (~20-50 books per series on page)

Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>

* chore: migration fix from merge

* chore: address comments

* fix: handle getBookPage for PDF/CBX

* chore: rename migration

* chore: komga specific series queries fix

* chore: komga tests fix

* chore: front end komga tests

---------

Co-authored-by: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com>
Co-authored-by: ACX <8075870+acx10@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
farfromrefuge
2026-01-19 16:44:53 +01:00
committed by GitHub
parent 4253322d2c
commit 69c3c88375
43 changed files with 3071 additions and 5 deletions

View File

@@ -0,0 +1,44 @@
package com.adityachandel.booklore.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ser.BeanPropertyWriter;
import com.fasterxml.jackson.databind.ser.BeanSerializerModifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import java.util.List;
import java.util.stream.Collectors;
/**
* Jackson configuration for Komga API clean mode.
*/
@Configuration
public class JacksonConfig {
public static final String KOMGA_CLEAN_OBJECT_MAPPER = "komgaCleanObjectMapper";
@Bean(name = KOMGA_CLEAN_OBJECT_MAPPER)
public ObjectMapper komgaCleanObjectMapper(Jackson2ObjectMapperBuilder builder) {
ObjectMapper mapper = builder.build();
// Register the custom serializer modifier on this dedicated mapper only
mapper.setSerializerFactory(
mapper.getSerializerFactory().withSerializerModifier(new BeanSerializerModifier() {
@Override
public List<BeanPropertyWriter> changeProperties(
com.fasterxml.jackson.databind.SerializationConfig config,
com.fasterxml.jackson.databind.BeanDescription beanDesc,
List<BeanPropertyWriter> beanProperties) {
return beanProperties.stream()
.map(KomgaCleanBeanPropertyWriter::new)
.collect(Collectors.toList());
}
})
);
return mapper;
}
}

View File

@@ -0,0 +1,48 @@
package com.adityachandel.booklore.config;
import com.adityachandel.booklore.context.KomgaCleanContext;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.BeanPropertyWriter;
import java.util.Collection;
/**
* Custom BeanPropertyWriter that handles clean mode filtering.
* When clean mode is enabled:
* - Fields ending with "Lock" are excluded
* - Null values are excluded
* - Empty arrays/collections are excluded
*/
public class KomgaCleanBeanPropertyWriter extends BeanPropertyWriter {
protected KomgaCleanBeanPropertyWriter(BeanPropertyWriter base) {
super(base);
}
@Override
public void serializeAsField(Object bean, JsonGenerator gen, SerializerProvider prov) throws Exception {
if (KomgaCleanContext.isCleanMode()) {
String propertyName = getName();
// Exclude properties ending with "Lock"
if (propertyName.endsWith("Lock")) {
return;
}
// Exclude null values
Object value = get(bean);
if (value == null) {
return;
}
// Exclude empty collections/arrays
if (value instanceof Collection && ((Collection<?>) value).isEmpty()) {
return;
}
}
// Default behavior
super.serializeAsField(bean, gen, prov);
}
}

View File

@@ -1,5 +1,7 @@
package com.adityachandel.booklore.config;
import com.adityachandel.booklore.interceptor.KomgaCleanInterceptor;
import com.adityachandel.booklore.interceptor.KomgaEnabledInterceptor;
import com.adityachandel.booklore.interceptor.OpdsEnabledInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
@@ -11,10 +13,16 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
public class WebMvcConfig implements WebMvcConfigurer {
private final OpdsEnabledInterceptor opdsEnabledInterceptor;
private final KomgaEnabledInterceptor komgaEnabledInterceptor;
private final KomgaCleanInterceptor komgaCleanInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(opdsEnabledInterceptor)
.addPathPatterns("/api/v1/opds/**", "/api/v2/opds/**");
registry.addInterceptor(komgaEnabledInterceptor)
.addPathPatterns("/komga/api/**");
registry.addInterceptor(komgaCleanInterceptor)
.addPathPatterns("/komga/api/**");
}
}

View File

@@ -88,6 +88,28 @@ public class SecurityConfig {
@Bean
@Order(2)
public SecurityFilterChain komgaBasicAuthSecurityChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/komga/api/v1/**", "/komga/api/v2/**")
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.httpBasic(basic -> basic
.realmName("Booklore Komga API")
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setHeader("WWW-Authenticate", "Basic realm=\"Booklore Komga API\"");
response.getWriter().write("HTTP Status 401 - " + authException.getMessage());
})
);
return http.build();
}
@Bean
@Order(3)
public SecurityFilterChain koreaderSecurityChain(HttpSecurity http, KoreaderAuthFilter koreaderAuthFilter) throws Exception {
http
.securityMatcher("/api/koreader/**")
@@ -185,4 +207,4 @@ public class SecurityConfig {
return source;
}
}
}

View File

@@ -0,0 +1,24 @@
package com.adityachandel.booklore.context;
/**
* ThreadLocal context to track whether the Komga API "clean" mode is enabled.
* When clean mode is enabled:
* - Fields ending with "Lock" are excluded from JSON serialization
* - Null values are excluded from JSON serialization
* - Metadata fields (language, summary, etc.) are allowed to be null
*/
public class KomgaCleanContext {
private static final ThreadLocal<Boolean> cleanModeEnabled = ThreadLocal.withInitial(() -> false);
public static void setCleanMode(boolean enabled) {
cleanModeEnabled.set(enabled);
}
public static boolean isCleanMode() {
return cleanModeEnabled.get();
}
public static void clear() {
cleanModeEnabled.remove();
}
}

View File

@@ -0,0 +1,208 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.config.JacksonConfig;
import com.adityachandel.booklore.mapper.komga.KomgaMapper;
import com.adityachandel.booklore.model.dto.komga.*;
import com.adityachandel.booklore.service.book.BookService;
import com.adityachandel.booklore.service.komga.KomgaService;
import com.adityachandel.booklore.service.opds.OpdsUserV2Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "Komga API", description = "Komga-compatible API endpoints. " +
"All endpoints support a 'clean' query parameter (default: false). " +
"When present (?clean or ?clean=true), responses exclude fields ending with 'Lock', null values, and empty arrays, " +
"resulting in smaller and cleaner JSON payloads.")
@Slf4j
@RestController
@RequestMapping(value = "/komga/api", produces = "application/json")
@RequiredArgsConstructor
public class KomgaController {
private final KomgaService komgaService;
private final BookService bookService;
private final OpdsUserV2Service opdsUserV2Service;
private final KomgaMapper komgaMapper;
// Inject the dedicated komga mapper bean
private final @Qualifier(JacksonConfig.KOMGA_CLEAN_OBJECT_MAPPER) ObjectMapper komgaCleanObjectMapper;
// Helper to serialize using the komga-clean mapper
private ResponseEntity<String> writeJson(Object body) {
try {
String json = komgaCleanObjectMapper.writeValueAsString(body);
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(json);
} catch (Exception e) {
log.error("Failed to serialize Komga response", e);
return ResponseEntity.status(500).build();
}
}
// ==================== Libraries ====================
@Operation(summary = "List all libraries")
@GetMapping("/v1/libraries")
public ResponseEntity<String> getAllLibraries() {
List<KomgaLibraryDto> libraries = komgaService.getAllLibraries();
return writeJson(libraries);
}
@Operation(summary = "Get library details")
@GetMapping("/v1/libraries/{libraryId}")
public ResponseEntity<String> getLibrary(
@Parameter(description = "Library ID") @PathVariable Long libraryId) {
return writeJson(komgaService.getLibraryById(libraryId));
}
// ==================== Series ====================
@Operation(summary = "List series")
@GetMapping("/v1/series")
public ResponseEntity<String> getAllSeries(
@Parameter(description = "Library ID filter") @RequestParam(required = false, name = "library_id") Long libraryId,
@Parameter(description = "Page number") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "Page size") @RequestParam(defaultValue = "20") int size,
@Parameter(description = "Return all books without paging") @RequestParam(defaultValue = "false") boolean unpaged) {
KomgaPageableDto<KomgaSeriesDto> result = komgaService.getAllSeries(libraryId, page, size, unpaged);
return writeJson(result);
}
@Operation(summary = "Get series details")
@GetMapping("/v1/series/{seriesId}")
public ResponseEntity<String> getSeries(
@Parameter(description = "Series ID") @PathVariable String seriesId) {
return writeJson(komgaService.getSeriesById(seriesId));
}
@Operation(summary = "List books in series")
@GetMapping("/v1/series/{seriesId}/books")
public ResponseEntity<String> getSeriesBooks(
@Parameter(description = "Series ID") @PathVariable String seriesId,
@Parameter(description = "Page number") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "Page size") @RequestParam(defaultValue = "20") int size,
@Parameter(description = "Return all books without paging") @RequestParam(defaultValue = "false") boolean unpaged) {
return writeJson(komgaService.getBooksBySeries(seriesId, page, size, unpaged));
}
@Operation(summary = "Get series thumbnail")
@GetMapping("/v1/series/{seriesId}/thumbnail")
public ResponseEntity<Resource> getSeriesThumbnail(
@Parameter(description = "Series ID") @PathVariable String seriesId) {
KomgaPageableDto<KomgaBookDto> books = komgaService.getBooksBySeries(seriesId, 0, 1, false);
if (books.getContent().isEmpty()) {
return ResponseEntity.notFound().build();
}
Long firstBookId = Long.parseLong(books.getContent().get(0).getId());
Resource coverImage = bookService.getBookThumbnail(firstBookId);
return ResponseEntity.ok()
.header("Content-Type", "image/jpeg")
.body(coverImage);
}
// ==================== Books ====================
@Operation(summary = "List books")
@GetMapping("/v1/books")
public ResponseEntity<String> getAllBooks(
@Parameter(description = "Library ID filter") @RequestParam(required = false, name = "library_id") Long libraryId,
@Parameter(description = "Page number") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "Page size") @RequestParam(defaultValue = "20") int size) {
KomgaPageableDto<KomgaBookDto> result = komgaService.getAllBooks(libraryId, page, size);
return writeJson(result);
}
@Operation(summary = "Get book details")
@GetMapping("/v1/books/{bookId}")
public ResponseEntity<String> getBook(
@Parameter(description = "Book ID") @PathVariable Long bookId) {
return writeJson(komgaService.getBookById(bookId));
}
@Operation(summary = "Get book pages metadata")
@GetMapping("/v1/books/{bookId}/pages")
public ResponseEntity<String> getBookPages(
@Parameter(description = "Book ID") @PathVariable Long bookId) {
return writeJson(komgaService.getBookPages(bookId));
}
@Operation(summary = "Get book page image")
@GetMapping("/v1/books/{bookId}/pages/{pageNumber}")
public ResponseEntity<Resource> getBookPage(
@Parameter(description = "Book ID") @PathVariable Long bookId,
@Parameter(description = "Page number") @PathVariable Integer pageNumber,
@Parameter(description = "Convert image format (e.g., 'png')") @RequestParam(required = false) String convert) {
try {
boolean convertToPng = "png".equalsIgnoreCase(convert);
Resource pageImage = komgaService.getBookPageImage(bookId, pageNumber, convertToPng);
// Note: When not converting, we assume JPEG as most CBZ files contain JPEG images,
// but the actual format may vary (PNG, WebP, etc.)
String contentType = convertToPng ? "image/png" : "image/jpeg";
return ResponseEntity.ok()
.header("Content-Type", contentType)
.body(pageImage);
} catch (Exception e) {
log.error("Failed to get page {} from book {}", pageNumber, bookId, e);
return ResponseEntity.notFound().build();
}
}
@Operation(summary = "Download book file")
@GetMapping("/v1/books/{bookId}/file")
public ResponseEntity<Resource> downloadBook(
@Parameter(description = "Book ID") @PathVariable Long bookId) {
return bookService.downloadBook(bookId);
}
@Operation(summary = "Get book thumbnail")
@GetMapping("/v1/books/{bookId}/thumbnail")
public ResponseEntity<Resource> getBookThumbnail(
@Parameter(description = "Book ID") @PathVariable Long bookId) {
Resource coverImage = bookService.getBookThumbnail(bookId);
return ResponseEntity.ok()
.header("Content-Type", "image/jpeg")
.body(coverImage);
}
// ==================== Users ====================
@Operation(summary = "Get current user details")
@GetMapping("/v2/users/me")
public ResponseEntity<String> getCurrentUser(Authentication authentication) {
if (authentication == null || authentication.getName() == null) {
return ResponseEntity.status(401).build();
}
String username = authentication.getName();
var opdsUser = opdsUserV2Service.findByUsername(username);
if (opdsUser == null) {
return ResponseEntity.notFound().build();
}
return writeJson(komgaMapper.toKomgaUserDto(opdsUser));
}
// ==================== Collections ====================
@Operation(summary = "List collections")
@GetMapping("/v1/collections")
public ResponseEntity<String> getCollections(
@Parameter(description = "Page number") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "Page size") @RequestParam(defaultValue = "20") int size,
@Parameter(description = "Return all collections without paging") @RequestParam(defaultValue = "false") boolean unpaged) {
return writeJson(komgaService.getCollections(page, size, unpaged));
}
}

View File

@@ -0,0 +1,39 @@
package com.adityachandel.booklore.interceptor;
import com.adityachandel.booklore.context.KomgaCleanContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* Interceptor to handle the "clean" query parameter for Komga API endpoints.
* When the "clean" parameter is present (with or without a value) or set to "true",
* it enables clean mode which filters out "Lock" fields, null values, and empty arrays
* from the JSON response. Supports both ?clean and ?clean=true syntax.
*/
@Component
public class KomgaCleanInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
// Only apply to Komga API endpoints
if (requestURI != null && requestURI.startsWith("/komga/api")) {
String cleanParam = request.getParameter("clean");
// Enable clean mode if parameter is present (even without value) or explicitly set to "true"
// Supports both ?clean and ?clean=true
boolean cleanMode = cleanParam != null && (cleanParam.isEmpty() || "true".equalsIgnoreCase(cleanParam));
KomgaCleanContext.setCleanMode(cleanMode);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// Clean up ThreadLocal to prevent memory leaks
KomgaCleanContext.clear();
}
}

View File

@@ -0,0 +1,30 @@
package com.adityachandel.booklore.interceptor;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
@RequiredArgsConstructor
public class KomgaEnabledInterceptor implements HandlerInterceptor {
private final AppSettingService appSettingService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
if (uri.startsWith("/komga/api/")) {
boolean komgaEnabled = appSettingService.getAppSettings().isKomgaApiEnabled();
if (!komgaEnabled) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Komga API is disabled.");
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,370 @@
package com.adityachandel.booklore.mapper.komga;
import com.adityachandel.booklore.context.KomgaCleanContext;
import com.adityachandel.booklore.model.dto.MagicShelf;
import com.adityachandel.booklore.model.dto.komga.*;
import com.adityachandel.booklore.model.entity.*;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
public class KomgaMapper {
private final AppSettingService appSettingService;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE;
private static final String UNKNOWN_SERIES = "Unknown Series";
public KomgaLibraryDto toKomgaLibraryDto(LibraryEntity library) {
return KomgaLibraryDto.builder()
.id(library.getId().toString())
.name(library.getName())
.root(library.getLibraryPaths() != null && !library.getLibraryPaths().isEmpty()
? library.getLibraryPaths().get(0).getPath()
: "")
.unavailable(false)
.build();
}
public KomgaBookDto toKomgaBookDto(BookEntity book) {
BookMetadataEntity metadata = book.getMetadata();
BookFileEntity bookFile = book.getPrimaryBookFile();
String seriesId = generateSeriesId(book);
return KomgaBookDto.builder()
.id(book.getId().toString())
.seriesId(seriesId)
.seriesTitle(getBookSeriesName(book))
.libraryId(book.getLibrary().getId().toString())
.name(metadata != null ? metadata.getTitle() : bookFile.getFileName())
.url("/komga/api/v1/books/" + book.getId())
.number(metadata != null && metadata.getSeriesNumber() != null
? metadata.getSeriesNumber().intValue()
: 1)
.created(book.getAddedOn())
.lastModified(book.getAddedOn())
.fileLastModified(book.getAddedOn())
.sizeBytes(bookFile.getFileSizeKb() != null ? bookFile.getFileSizeKb() * 1024 : 0L)
.size(formatFileSize(bookFile.getFileSizeKb()))
.media(toKomgaMediaDto(book, metadata))
.metadata(toKomgaBookMetadataDto(metadata))
.deleted(book.getDeleted())
.fileHash(bookFile.getCurrentHash())
.oneshot(false)
.build();
}
public KomgaSeriesDto toKomgaSeriesDto(String seriesName, Long libraryId, List<BookEntity> books) {
if (books == null || books.isEmpty()) {
return null;
}
BookEntity firstBook = books.get(0);
String seriesId = generateSeriesId(firstBook);
// Aggregate metadata from all books
KomgaSeriesMetadataDto metadata = aggregateSeriesMetadata(seriesName, books);
KomgaBookMetadataAggregationDto booksMetadata = aggregateBooksMetadata(books);
return KomgaSeriesDto.builder()
.id(seriesId)
.libraryId(libraryId.toString())
.name(seriesName)
.url("/komga/api/v1/series/" + seriesId)
.created(firstBook.getAddedOn())
.lastModified(firstBook.getAddedOn())
.fileLastModified(firstBook.getAddedOn())
.booksCount(books.size())
.booksReadCount(0)
.booksUnreadCount(books.size())
.booksInProgressCount(0)
.metadata(metadata)
.booksMetadata(booksMetadata)
.deleted(false)
.oneshot(books.size() == 1)
.build();
}
private KomgaMediaDto toKomgaMediaDto(BookEntity book, BookMetadataEntity metadata) {
BookFileEntity bookFile = book.getPrimaryBookFile();
String mediaType = getMediaType(bookFile.getBookType());
Integer pageCount = metadata != null && metadata.getPageCount() != null ? metadata.getPageCount() : 0;
return KomgaMediaDto.builder()
.status("READY")
.mediaType(mediaType)
.mediaProfile(getMediaProfile(bookFile.getBookType()))
.pagesCount(pageCount)
.build();
}
private KomgaBookMetadataDto toKomgaBookMetadataDto(BookMetadataEntity metadata) {
if (metadata == null) {
return KomgaBookMetadataDto.builder().build();
}
List<KomgaAuthorDto> authors = new ArrayList<>();
if (metadata.getAuthors() != null) {
authors = metadata.getAuthors().stream()
.map(author -> KomgaAuthorDto.builder()
.name(author.getName())
.role("writer")
.build())
.collect(Collectors.toList());
}
List<String> tags = new ArrayList<>();
if (metadata.getTags() != null) {
tags = metadata.getTags().stream()
.map(TagEntity::getName)
.collect(Collectors.toList());
}
return KomgaBookMetadataDto.builder()
.title(nullIfEmptyInCleanMode(metadata.getTitle(), ""))
.titleLock(metadata.getTitleLocked())
.summary(nullIfEmptyInCleanMode(metadata.getDescription(), ""))
.summaryLock(metadata.getDescriptionLocked())
.number(nullIfEmptyInCleanMode(metadata.getSeriesNumber(), 1.0F).toString())
.numberLock(metadata.getSeriesNumberLocked())
.numberSort(nullIfEmptyInCleanMode(metadata.getSeriesNumber(), 1.0F))
.numberSortLock(metadata.getSeriesNumberLocked())
.releaseDate(metadata.getPublishedDate() != null
? metadata.getPublishedDate().format(DATE_FORMATTER)
: null)
.releaseDateLock(metadata.getPublishedDateLocked())
.authors(authors)
.authorsLock(metadata.getAuthorsLocked())
.tags(tags)
.tagsLock(metadata.getTagsLocked())
.isbn(metadata.getIsbn13() != null ? metadata.getIsbn13() : metadata.getIsbn10())
.isbnLock(metadata.getIsbn13Locked())
.build();
}
private KomgaSeriesMetadataDto aggregateSeriesMetadata(String seriesName, List<BookEntity> books) {
BookEntity firstBook = books.get(0);
BookMetadataEntity firstMetadata = firstBook.getMetadata();
List<String> genres = new ArrayList<>();
List<String> tags = new ArrayList<>();
if (firstMetadata != null) {
if (firstMetadata.getCategories() != null) {
genres = firstMetadata.getCategories().stream()
.map(CategoryEntity::getName)
.collect(Collectors.toList());
}
if (firstMetadata.getTags() != null) {
tags = firstMetadata.getTags().stream()
.map(TagEntity::getName)
.collect(Collectors.toList());
}
}
String language = firstMetadata != null ? firstMetadata.getLanguage() : null;
String description = firstMetadata != null ? firstMetadata.getDescription() : null;
String publisher = firstMetadata != null ? firstMetadata.getPublisher() : null;
return KomgaSeriesMetadataDto.builder()
.status("ONGOING")
.statusLock(false)
.title(seriesName)
.titleLock(false)
.titleSort(seriesName)
.titleSortLock(false)
.summary(nullIfEmptyInCleanMode(description, ""))
.summaryLock(false)
.publisher(nullIfEmptyInCleanMode(publisher, ""))
.publisherLock(false)
.language(nullIfEmptyInCleanMode(language, "en"))
.languageLock(false)
.genres(genres)
.genresLock(false)
.tags(tags)
.tagsLock(false)
.totalBookCount(books.size())
.totalBookCountLock(false)
// not used but required right now by Mihon/komga apps
.ageRatingLock(false)
.readingDirection("LEFT_TO_RIGHT")
.readingDirectionLock(false)
.build();
}
private KomgaBookMetadataAggregationDto aggregateBooksMetadata(List<BookEntity> books) {
Set<String> authorNames = new HashSet<>();
Set<String> allTags = new HashSet<>();
String releaseDate = null;
String summary = null;
BookEntity firstBook = books.get(0);
for (BookEntity book : books) {
BookMetadataEntity metadata = book.getMetadata();
if (metadata != null) {
if (metadata.getAuthors() != null) {
metadata.getAuthors().forEach(author -> authorNames.add(author.getName()));
}
if (metadata.getTags() != null) {
metadata.getTags().forEach(tag -> allTags.add(tag.getName()));
}
if (releaseDate == null && metadata.getPublishedDate() != null) {
releaseDate = metadata.getPublishedDate().format(DATE_FORMATTER);
}
if (summary == null && metadata.getDescription() != null) {
summary = metadata.getDescription();
}
}
}
List<KomgaAuthorDto> authors = authorNames.stream()
.map(name -> KomgaAuthorDto.builder().name(name).role("writer").build())
.collect(Collectors.toList());
return KomgaBookMetadataAggregationDto.builder()
.authors(authors)
.tags(new ArrayList<>(allTags))
.created(firstBook.getAddedOn())
.lastModified(firstBook.getAddedOn())
.releaseDate(releaseDate)
.summary(nullIfEmptyInCleanMode(summary, ""))
// summaryNumber is typically empty, but in clean mode should be null to be filtered
.summaryNumber(nullIfEmptyInCleanMode(null, ""))
.summaryLock(false)
.build();
}
public String getBookSeriesName(BookEntity book) {
boolean groupUnknown = appSettingService.getAppSettings().isKomgaGroupUnknown();
BookMetadataEntity metadata = book.getMetadata();
BookFileEntity bookFile = book.getPrimaryBookFile();
String bookSeriesName = metadata != null && metadata.getSeriesName() != null
? metadata.getSeriesName()
: (groupUnknown ? UNKNOWN_SERIES : (metadata.getTitle() != null ? metadata.getTitle() : bookFile.getFileName() ));
return bookSeriesName;
}
public String getUnknownSeriesName() {
return UNKNOWN_SERIES;
}
private String generateSeriesId(BookEntity book) {
String seriesName = getBookSeriesName(book);
Long libraryId = book.getLibrary().getId();
// Generate a pseudo-ID based on library and series name
return libraryId + "-" + seriesName.toLowerCase().replaceAll("[^a-z0-9]+", "-");
}
private String getMediaType(BookFileType bookType) {
if (bookType == null) {
return "application/zip";
}
return switch (bookType) {
case PDF -> "application/pdf";
case EPUB -> "application/epub+zip";
case CBX -> "application/x-cbz";
case FB2 -> "application/fictionbook2+zip";
case MOBI -> "application/x-mobipocket-ebook";
case AZW3 -> "application/vnd.amazon.ebook";
};
}
private String getMediaProfile(BookFileType bookType) {
if (bookType == null) {
return "UNKNOWN";
}
return switch (bookType) {
case PDF -> "PDF";
case MOBI -> "EPUB";
case AZW3 -> "EPUB";
case EPUB -> "EPUB";
case CBX -> "DIVINA"; // DIVINA is for comic books
case FB2 -> "DIVINA";
};
}
private String formatFileSize(Long fileSizeKb) {
if (fileSizeKb == null || fileSizeKb == 0) {
return "0 B";
}
long bytes = fileSizeKb * 1024;
if (bytes < 1024) {
return bytes + " B";
} else if (bytes < 1024 * 1024) {
return (bytes / 1024) + " KB";
} else if (bytes < 1024 * 1024 * 1024) {
return (bytes / (1024 * 1024)) + " MB";
} else {
return (bytes / (1024 * 1024 * 1024)) + " GB";
}
}
/**
* Helper method to return null for empty strings in clean mode.
* In clean mode, we want to allow null values so they can be filtered out.
*/
private String nullIfEmptyInCleanMode(String value, String defaultValue) {
if (KomgaCleanContext.isCleanMode()) {
return (value != null && !value.isEmpty()) ? value : null;
}
return value != null ? value : defaultValue;
}
/**
* Helper method to return null for empty integer in clean mode.
* In clean mode, we want to allow null values so they can be filtered out.
*/
private Integer nullIfEmptyInCleanMode(Integer value, Integer defaultValue) {
if (KomgaCleanContext.isCleanMode()) {
return (value != null) ? value : null;
}
return value != null ? value : defaultValue;
}
/**
* Helper method to return null for empty float in clean mode.
* In clean mode, we want to allow null values so they can be filtered out.
*/
private Float nullIfEmptyInCleanMode(Float value, Float defaultValue) {
if (KomgaCleanContext.isCleanMode()) {
return (value != null) ? value : null;
}
return value != null ? value : defaultValue;
}
public KomgaUserDto toKomgaUserDto(OpdsUserV2Entity opdsUser) {
return KomgaUserDto.builder()
.id(opdsUser.getId().toString())
.email(opdsUser.getUsername() + "@booklore.local")
.roles(List.of("USER"))
.sharedAllLibraries(true)
.build();
}
public KomgaCollectionDto toKomgaCollectionDto(MagicShelf magicShelf, int seriesCount) {
String now = Instant.now()
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
return KomgaCollectionDto.builder()
.id(magicShelf.getId().toString())
.name(magicShelf.getName())
.ordered(false)
.seriesCount(seriesCount)
.createdDate(now)
.lastModifiedDate(now)
.build();
}
}

View File

@@ -0,0 +1,15 @@
package com.adityachandel.booklore.model.dto.komga;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KomgaAlternateTitleDto {
private String label;
private String title;
}

View File

@@ -0,0 +1,15 @@
package com.adityachandel.booklore.model.dto.komga;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KomgaAuthorDto {
private String name;
private String role;
}

View File

@@ -0,0 +1,35 @@
package com.adityachandel.booklore.model.dto.komga;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KomgaBookDto {
private String id;
private String seriesId;
private String seriesTitle;
private String libraryId;
private String name;
private String url;
private Integer number;
private Instant created;
private Instant lastModified;
private Instant fileLastModified;
private Long sizeBytes;
private String size;
private KomgaMediaDto media;
private KomgaBookMetadataDto metadata;
private KomgaReadProgressDto readProgress;
private Boolean deleted;
private String fileHash;
private Boolean oneshot;
}

View File

@@ -0,0 +1,34 @@
package com.adityachandel.booklore.model.dto.komga;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KomgaBookMetadataAggregationDto {
@Builder.Default
private List<KomgaAuthorDto> authors = new ArrayList<>();
@Builder.Default
private List<String> tags = new ArrayList<>();
private String releaseDate;
private String summary;
private String summaryNumber;
private Boolean summaryLock;
private Instant created;
private Instant lastModified;
}

View File

@@ -0,0 +1,51 @@
package com.adityachandel.booklore.model.dto.komga;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KomgaBookMetadataDto {
private String title;
private Boolean titleLock;
private String summary;
private Boolean summaryLock;
private String number;
private Boolean numberLock;
private Float numberSort;
private Boolean numberSortLock;
private String releaseDate;
private Boolean releaseDateLock;
@Builder.Default
private List<KomgaAuthorDto> authors = new ArrayList<>();
private Boolean authorsLock;
@Builder.Default
private List<String> tags = new ArrayList<>();
private Boolean tagsLock;
private String isbn;
private Boolean isbnLock;
@Builder.Default
private List<KomgaWebLinkDto> links = new ArrayList<>();
private Boolean linksLock;
private Instant created;
private Instant lastModified;
}

View File

@@ -0,0 +1,19 @@
package com.adityachandel.booklore.model.dto.komga;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KomgaCollectionDto {
private String id;
private String name;
private Boolean ordered;
private Integer seriesCount;
private String createdDate;
private String lastModifiedDate;
}

View File

@@ -0,0 +1,73 @@
package com.adityachandel.booklore.model.dto.komga;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KomgaLibraryDto {
private String id;
private String name;
private String root;
private Boolean unavailable;
// Scan options
@Builder.Default
private Boolean scanCbx = true;
@Builder.Default
private Boolean scanEpub = true;
@Builder.Default
private Boolean scanPdf = true;
@Builder.Default
private Boolean scanOnStartup = false;
@Builder.Default
private String scanInterval = "EVERY_6H";
@Builder.Default
private Boolean scanForceModifiedTime = false;
// Import options
@Builder.Default
private Boolean importComicInfoBook = true;
@Builder.Default
private Boolean importComicInfoSeries = true;
@Builder.Default
private Boolean importComicInfoCollection = true;
@Builder.Default
private Boolean importComicInfoReadList = true;
@Builder.Default
private Boolean importComicInfoSeriesAppendVolume = false;
@Builder.Default
private Boolean importEpubBook = true;
@Builder.Default
private Boolean importEpubSeries = true;
@Builder.Default
private Boolean importLocalArtwork = true;
@Builder.Default
private Boolean importBarcodeIsbn = true;
@Builder.Default
private Boolean importMylarSeries = true;
// Other options
@Builder.Default
private Boolean repairExtensions = false;
@Builder.Default
private Boolean convertToCbz = false;
@Builder.Default
private Boolean emptyTrashAfterScan = false;
@Builder.Default
private String seriesCover = "FIRST";
@Builder.Default
private Boolean hashFiles = true;
@Builder.Default
private Boolean hashPages = false;
@Builder.Default
private Boolean hashKoreader = false;
@Builder.Default
private Boolean analyzeDimensions = true;
}

View File

@@ -0,0 +1,22 @@
package com.adityachandel.booklore.model.dto.komga;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KomgaMediaDto {
private String status;
private String mediaType;
private String mediaProfile;
private Integer pagesCount;
private String comment;
private Boolean epubDivinaCompatible;
private Boolean epubIsKepub;
}

View File

@@ -0,0 +1,21 @@
package com.adityachandel.booklore.model.dto.komga;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KomgaPageDto {
private Integer number;
private String fileName;
private String mediaType;
private Integer width;
private Integer height;
private Long fileSize;
}

View File

@@ -0,0 +1,26 @@
package com.adityachandel.booklore.model.dto.komga;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KomgaPageableDto<T> {
private List<T> content;
private Integer number;
private Integer size;
private Integer numberOfElements;
private Integer totalElements;
private Integer totalPages;
private Boolean first;
private Boolean last;
private Boolean empty;
}

View File

@@ -0,0 +1,22 @@
package com.adityachandel.booklore.model.dto.komga;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KomgaReadProgressDto {
private Integer page;
private Boolean completed;
private Instant readDate;
private Instant created;
private Instant lastModified;
}

View File

@@ -0,0 +1,32 @@
package com.adityachandel.booklore.model.dto.komga;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KomgaSeriesDto {
private String id;
private String libraryId;
private String name;
private String url;
private Instant created;
private Instant lastModified;
private Instant fileLastModified;
private Integer booksCount;
private Integer booksReadCount;
private Integer booksUnreadCount;
private Integer booksInProgressCount;
private KomgaSeriesMetadataDto metadata;
private KomgaBookMetadataAggregationDto booksMetadata;
private Boolean deleted;
private Boolean oneshot;
}

View File

@@ -0,0 +1,68 @@
package com.adityachandel.booklore.model.dto.komga;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KomgaSeriesMetadataDto {
private String status;
private Boolean statusLock;
private String title;
private Boolean titleLock;
private String titleSort;
private Boolean titleSortLock;
private String summary;
private Boolean summaryLock;
private String readingDirection;
private Boolean readingDirectionLock;
private String publisher;
private Boolean publisherLock;
private Integer ageRating;
private Boolean ageRatingLock;
private String language;
private Boolean languageLock;
@Builder.Default
private List<String> genres = new ArrayList<>();
private Boolean genresLock;
@Builder.Default
private List<String> tags = new ArrayList<>();
private Boolean tagsLock;
private Integer totalBookCount;
private Boolean totalBookCountLock;
@Builder.Default
private List<KomgaAlternateTitleDto> alternateTitles = new ArrayList<>();
private Boolean alternateTitlesLock;
@Builder.Default
private List<KomgaWebLinkDto> links = new ArrayList<>();
private Boolean linksLock;
@Builder.Default
private List<String> sharingLabels = new ArrayList<>();
private Boolean sharingLabelsLock;
private Instant created;
private Instant lastModified;
}

View File

@@ -0,0 +1,35 @@
package com.adityachandel.booklore.model.dto.komga;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KomgaUserDto {
private String id;
private String email;
@Builder.Default
private List<String> roles = new ArrayList<>();
@Builder.Default
private Boolean sharedAllLibraries = true;
@Builder.Default
private List<String> sharedLibrariesIds = new ArrayList<>();
@Builder.Default
private List<String> labelsAllow = new ArrayList<>();
@Builder.Default
private List<String> labelsExclude = new ArrayList<>();
}

View File

@@ -0,0 +1,15 @@
package com.adityachandel.booklore.model.dto.komga;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KomgaWebLinkDto {
private String label;
private String url;
}

View File

@@ -14,6 +14,8 @@ public enum AppSettingKey {
OIDC_AUTO_PROVISION_DETAILS ("oidc_auto_provision_details", true, false, List.of(PermissionType.ADMIN)),
KOBO_SETTINGS ("kobo_settings", true, false, List.of(PermissionType.ADMIN)),
OPDS_SERVER_ENABLED ("opds_server_enabled", false, false, List.of(PermissionType.ADMIN)),
KOMGA_API_ENABLED ("komga_api_enabled", false, false, List.of(PermissionType.ADMIN)),
KOMGA_GROUP_UNKNOWN ("komga_group_unknown", false, false, List.of(PermissionType.ADMIN)),
// ADMIN + MANAGE_METADATA_CONFIG
QUICK_BOOK_MATCH ("quick_book_match", true, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_METADATA_CONFIG)),

View File

@@ -18,6 +18,8 @@ public class AppSettings {
private boolean autoBookSearch;
private boolean similarBookRecommendation;
private boolean opdsServerEnabled;
private boolean komgaApiEnabled;
private boolean komgaGroupUnknown;
private String uploadPattern;
private Integer pdfCacheSizeInMb;
private Integer maxFileUploadSizeInMb;

View File

@@ -157,4 +157,154 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
@Param("bookId") Long bookId,
@Param("libraryId") Long libraryId,
@Param("libraryPath") LibraryPathEntity libraryPath);
/**
* Get distinct series names for a library when groupUnknown=true.
* Books without series name are grouped as "Unknown Series".
*/
@Query("""
SELECT DISTINCT
CASE
WHEN m.seriesName IS NOT NULL THEN m.seriesName
ELSE :unknownSeriesName
END as seriesName
FROM BookEntity b
LEFT JOIN b.metadata m
WHERE b.library.id = :libraryId
AND (b.deleted IS NULL OR b.deleted = false)
ORDER BY seriesName
""")
List<String> findDistinctSeriesNamesGroupedByLibraryId(
@Param("libraryId") Long libraryId,
@Param("unknownSeriesName") String unknownSeriesName);
/**
* Get distinct series names across all libraries when groupUnknown=true.
* Books without series name are grouped as "Unknown Series".
*/
@Query("""
SELECT DISTINCT
CASE
WHEN m.seriesName IS NOT NULL THEN m.seriesName
ELSE :unknownSeriesName
END as seriesName
FROM BookEntity b
LEFT JOIN b.metadata m
WHERE (b.deleted IS NULL OR b.deleted = false)
ORDER BY seriesName
""")
List<String> findDistinctSeriesNamesGrouped(@Param("unknownSeriesName") String unknownSeriesName);
/**
* Get distinct series names for a library when groupUnknown=false.
* Each book without series gets its own entry (title or filename).
*/
@Query("""
SELECT DISTINCT
CASE
WHEN m.seriesName IS NOT NULL THEN m.seriesName
WHEN m.title IS NOT NULL THEN m.title
ELSE (
SELECT bf2.fileName FROM BookFileEntity bf2
WHERE bf2.book = b
AND bf2.isBookFormat = true
AND bf2.id = (
SELECT MIN(bf3.id) FROM BookFileEntity bf3
WHERE bf3.book = b AND bf3.isBookFormat = true
)
)
END as seriesName
FROM BookEntity b
LEFT JOIN b.metadata m
WHERE b.library.id = :libraryId
AND (b.deleted IS NULL OR b.deleted = false)
ORDER BY seriesName
""")
List<String> findDistinctSeriesNamesUngroupedByLibraryId(@Param("libraryId") Long libraryId);
/**
* Get distinct series names across all libraries when groupUnknown=false.
* Each book without series gets its own entry (title or filename).
*/
@Query("""
SELECT DISTINCT
CASE
WHEN m.seriesName IS NOT NULL THEN m.seriesName
WHEN m.title IS NOT NULL THEN m.title
ELSE (
SELECT bf2.fileName FROM BookFileEntity bf2
WHERE bf2.book = b
AND bf2.isBookFormat = true
AND bf2.id = (
SELECT MIN(bf3.id) FROM BookFileEntity bf3
WHERE bf3.book = b AND bf3.isBookFormat = true
)
)
END as seriesName
FROM BookEntity b
LEFT JOIN b.metadata m
WHERE (b.deleted IS NULL OR b.deleted = false)
ORDER BY seriesName
""")
List<String> findDistinctSeriesNamesUngrouped();
/**
* Find books by series name for a library when groupUnknown=true.
* Uses the first bookFile.fileName as fallback when metadata.seriesName is null.
*/
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"})
@Query("""
SELECT DISTINCT b FROM BookEntity b
LEFT JOIN b.metadata m
LEFT JOIN b.bookFiles bf
WHERE b.library.id = :libraryId
AND (
(m.seriesName = :seriesName)
OR (
m.seriesName IS NULL
AND bf.isBookFormat = true
AND bf.id = (
SELECT MIN(bf2.id) FROM BookFileEntity bf2
WHERE bf2.book = b AND bf2.isBookFormat = true
)
AND bf.fileName = :seriesName
)
)
AND (b.deleted IS NULL OR b.deleted = false)
ORDER BY COALESCE(m.seriesNumber, 0)
""")
List<BookEntity> findBooksBySeriesNameGroupedByLibraryId(
@Param("seriesName") String seriesName,
@Param("libraryId") Long libraryId,
@Param("unknownSeriesName") String unknownSeriesName);
/**
* Find books by series name for a library when groupUnknown=false.
* Matches by series name, or by title/filename for books without series.
*/
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"})
@Query("""
SELECT b FROM BookEntity b
LEFT JOIN b.metadata m
LEFT JOIN b.bookFiles bf
WHERE b.library.id = :libraryId
AND (
(m.seriesName = :seriesName)
OR (m.seriesName IS NULL AND m.title = :seriesName)
OR (
m.seriesName IS NULL AND m.title IS NULL
AND bf.isBookFormat = true
AND bf.id = (
SELECT MIN(bf2.id) FROM BookFileEntity bf2
WHERE bf2.book = b AND bf2.isBookFormat = true
)
AND bf.fileName = :seriesName
)
)
AND (b.deleted IS NULL OR b.deleted = false)
ORDER BY COALESCE(m.seriesNumber, 0)
""")
List<BookEntity> findBooksBySeriesNameUngroupedByLibraryId(
@Param("seriesName") String seriesName,
@Param("libraryId") Long libraryId);
}

View File

@@ -141,6 +141,8 @@ public class AppSettingService {
builder.uploadPattern(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.UPLOAD_FILE_PATTERN, "{authors}/<{series}/><{seriesIndex}. >{title}< - {authors}>< ({year})>"));
builder.similarBookRecommendation(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.SIMILAR_BOOK_RECOMMENDATION, "true")));
builder.opdsServerEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.OPDS_SERVER_ENABLED, "false")));
builder.komgaApiEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.KOMGA_API_ENABLED, "false")));
builder.komgaGroupUnknown(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.KOMGA_GROUP_UNKNOWN, "true")));
builder.telemetryEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.TELEMETRY_ENABLED, "true")));
builder.pdfCacheSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.PDF_CACHE_SIZE_IN_MB, "5120")));
builder.maxFileUploadSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.MAX_FILE_UPLOAD_SIZE_IN_MB, "100")));

View File

@@ -0,0 +1,422 @@
package com.adityachandel.booklore.service.komga;
import com.adityachandel.booklore.mapper.komga.KomgaMapper;
import com.adityachandel.booklore.model.dto.MagicShelf;
import com.adityachandel.booklore.model.dto.komga.*;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.repository.LibraryRepository;
import com.adityachandel.booklore.service.MagicShelfService;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import com.adityachandel.booklore.service.reader.CbxReaderService;
import com.adityachandel.booklore.service.reader.PdfReaderService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class KomgaService {
private final BookRepository bookRepository;
private final LibraryRepository libraryRepository;
private final KomgaMapper komgaMapper;
private final MagicShelfService magicShelfService;
private final CbxReaderService cbxReaderService;
private final PdfReaderService pdfReaderService;
private final AppSettingService appSettingService;
public List<KomgaLibraryDto> getAllLibraries() {
return libraryRepository.findAll().stream()
.map(komgaMapper::toKomgaLibraryDto)
.collect(Collectors.toList());
}
public KomgaLibraryDto getLibraryById(Long libraryId) {
LibraryEntity library = libraryRepository.findById(libraryId)
.orElseThrow(() -> new RuntimeException("Library not found"));
return komgaMapper.toKomgaLibraryDto(library);
}
public KomgaPageableDto<KomgaSeriesDto> getAllSeries(Long libraryId, int page, int size, boolean unpaged) {
log.debug("Getting all series for libraryId: {}, page: {}, size: {}", libraryId, page, size);
// Check if we should group unknown series
boolean groupUnknown = appSettingService.getAppSettings().isKomgaGroupUnknown();
// Get distinct series names directly from database (MUCH faster than loading all books)
List<String> sortedSeriesNames;
if (groupUnknown) {
// Use optimized query that groups books without series as "Unknown Series"
if (libraryId != null) {
sortedSeriesNames = bookRepository.findDistinctSeriesNamesGroupedByLibraryId(
libraryId, komgaMapper.getUnknownSeriesName());
} else {
sortedSeriesNames = bookRepository.findDistinctSeriesNamesGrouped(
komgaMapper.getUnknownSeriesName());
}
} else {
// Use query that gives each book without series its own entry
if (libraryId != null) {
sortedSeriesNames = bookRepository.findDistinctSeriesNamesUngroupedByLibraryId(libraryId);
} else {
sortedSeriesNames = bookRepository.findDistinctSeriesNamesUngrouped();
}
}
log.debug("Found {} distinct series names from database (optimized)", sortedSeriesNames.size());
// Calculate pagination
int totalElements = sortedSeriesNames.size();
List<String> pageSeriesNames;
int actualPage;
int actualSize;
int totalPages;
if (unpaged) {
pageSeriesNames = sortedSeriesNames;
actualPage = 0;
actualSize = totalElements;
totalPages = totalElements > 0 ? 1 : 0;
} else {
totalPages = (int) Math.ceil((double) totalElements / size);
int fromIndex = Math.min(page * size, totalElements);
int toIndex = Math.min(fromIndex + size, totalElements);
pageSeriesNames = sortedSeriesNames.subList(fromIndex, toIndex);
actualPage = page;
actualSize = size;
}
// Now load books only for the series on this page (optimized - only loads what's needed)
List<KomgaSeriesDto> content = new ArrayList<>();
for (String seriesName : pageSeriesNames) {
try {
// Load only the books for this specific series
List<BookEntity> seriesBooks;
if (libraryId != null) {
if (groupUnknown) {
seriesBooks = bookRepository.findBooksBySeriesNameGroupedByLibraryId(
seriesName, libraryId, komgaMapper.getUnknownSeriesName());
} else {
seriesBooks = bookRepository.findBooksBySeriesNameUngroupedByLibraryId(
seriesName, libraryId);
}
} else {
// For all libraries, need to load all books and filter (less common case)
List<BookEntity> allBooks = bookRepository.findAllWithMetadata();
seriesBooks = allBooks.stream()
.filter(book -> komgaMapper.getBookSeriesName(book).equals(seriesName))
.collect(Collectors.toList());
}
if (!seriesBooks.isEmpty()) {
Long libId = seriesBooks.get(0).getLibrary().getId();
KomgaSeriesDto seriesDto = komgaMapper.toKomgaSeriesDto(seriesName, libId, seriesBooks);
if (seriesDto != null) {
content.add(seriesDto);
}
}
} catch (Exception e) {
log.error("Error mapping series: {}", seriesName, e);
}
}
log.debug("Mapped {} series DTOs for this page", content.size());
return KomgaPageableDto.<KomgaSeriesDto>builder()
.content(content)
.number(actualPage)
.size(actualSize)
.numberOfElements(content.size())
.totalElements(totalElements)
.totalPages(totalPages)
.first(actualPage == 0)
.last(totalElements == 0 || actualPage >= totalPages - 1)
.empty(content.isEmpty())
.build();
}
public KomgaSeriesDto getSeriesById(String seriesId) {
// Parse seriesId to extract library and series name
String[] parts = seriesId.split("-", 2);
if (parts.length < 2) {
throw new RuntimeException("Invalid series ID");
}
Long libraryId = Long.parseLong(parts[0]);
String seriesSlug = parts[1];
// Get books matching the series - optimized to query by series name
List<BookEntity> allSeriesBooks = bookRepository.findAllWithMetadataByLibraryId(libraryId);
// Find the series name that matches this slug
List<BookEntity> seriesBooks = allSeriesBooks.stream()
.filter(book -> {
String bookSeriesName = komgaMapper.getBookSeriesName(book);
String bookSeriesSlug = bookSeriesName.toLowerCase().replaceAll("[^a-z0-9]+", "-");
return bookSeriesSlug.equals(seriesSlug);
})
.collect(Collectors.toList());
if (seriesBooks.isEmpty()) {
throw new RuntimeException("Series not found");
}
String seriesName = komgaMapper.getBookSeriesName(seriesBooks.get(0));
return komgaMapper.toKomgaSeriesDto(seriesName, libraryId, seriesBooks);
}
public KomgaPageableDto<KomgaBookDto> getBooksBySeries(String seriesId, int page, int size, boolean unpaged) {
// Parse seriesId to extract library and series name
String[] parts = seriesId.split("-", 2);
if (parts.length < 2) {
throw new RuntimeException("Invalid series ID");
}
Long libraryId = Long.parseLong(parts[0]);
String seriesSlug = parts[1];
// Get all books for the library once
List<BookEntity> allBooks = bookRepository.findAllWithMetadataByLibraryId(libraryId);
// Filter and sort books for this series
List<BookEntity> seriesBooks = allBooks.stream()
.filter(book -> {
String bookSeriesName = komgaMapper.getBookSeriesName(book);
String bookSeriesSlug = bookSeriesName.toLowerCase().replaceAll("[^a-z0-9]+", "-");
return bookSeriesSlug.equals(seriesSlug);
})
.sorted(Comparator.comparing(book -> {
BookMetadataEntity metadata = book.getMetadata();
return metadata != null && metadata.getSeriesNumber() != null
? metadata.getSeriesNumber()
: 0f;
}))
.collect(Collectors.toList());
// Handle unpaged mode
int totalElements = seriesBooks.size();
List<KomgaBookDto> content;
int actualPage;
int actualSize;
int totalPages;
if (unpaged) {
// Return all books without pagination
content = seriesBooks.stream()
.map(book -> komgaMapper.toKomgaBookDto(book))
.collect(Collectors.toList());
actualPage = 0;
actualSize = totalElements;
totalPages = totalElements > 0 ? 1 : 0;
} else {
// Paginate
totalPages = (int) Math.ceil((double) totalElements / size);
int fromIndex = Math.min(page * size, totalElements);
int toIndex = Math.min(fromIndex + size, totalElements);
content = seriesBooks.subList(fromIndex, toIndex).stream()
.map(book -> komgaMapper.toKomgaBookDto(book))
.collect(Collectors.toList());
actualPage = page;
actualSize = size;
}
return KomgaPageableDto.<KomgaBookDto>builder()
.content(content)
.number(actualPage)
.size(actualSize)
.numberOfElements(content.size())
.totalElements(totalElements)
.totalPages(totalPages)
.first(actualPage == 0)
.last(totalElements == 0 || actualPage >= totalPages - 1)
.empty(content.isEmpty())
.build();
}
public KomgaPageableDto<KomgaBookDto> getAllBooks(Long libraryId, int page, int size) {
List<BookEntity> books;
if (libraryId != null) {
books = bookRepository.findAllWithMetadataByLibraryId(libraryId);
} else {
books = bookRepository.findAllWithMetadata();
}
// Manual pagination
int totalElements = books.size();
int totalPages = (int) Math.ceil((double) totalElements / size);
int fromIndex = Math.min(page * size, totalElements);
int toIndex = Math.min(fromIndex + size, totalElements);
List<KomgaBookDto> content = books.subList(fromIndex, toIndex).stream()
.map(book -> komgaMapper.toKomgaBookDto(book))
.collect(Collectors.toList());
return KomgaPageableDto.<KomgaBookDto>builder()
.content(content)
.number(page)
.size(size)
.numberOfElements(content.size())
.totalElements(totalElements)
.totalPages(totalPages)
.first(page == 0)
.last(page >= totalPages - 1)
.empty(content.isEmpty())
.build();
}
public KomgaBookDto getBookById(Long bookId) {
BookEntity book = bookRepository.findById(bookId)
.orElseThrow(() -> new RuntimeException("Book not found"));
return komgaMapper.toKomgaBookDto(book);
}
public List<KomgaPageDto> getBookPages(Long bookId) {
BookEntity book = bookRepository.findById(bookId)
.orElseThrow(() -> new RuntimeException("Book not found"));
BookMetadataEntity metadata = book.getMetadata();
Integer pageCount = metadata != null && metadata.getPageCount() != null ? metadata.getPageCount() : 0;
List<KomgaPageDto> pages = new ArrayList<>();
if (pageCount > 0) {
for (int i = 1; i <= pageCount; i++) {
pages.add(KomgaPageDto.builder()
.number(i)
.fileName("page-" + i)
.mediaType("image/jpeg")
.build());
}
}
return pages;
}
private Map<String, List<BookEntity>> groupBooksBySeries(List<BookEntity> books) {
Map<String, List<BookEntity>> seriesMap = new HashMap<>();
for (BookEntity book : books) {
String seriesName = komgaMapper.getBookSeriesName(book);
seriesMap.computeIfAbsent(seriesName, k -> new ArrayList<>()).add(book);
}
return seriesMap;
}
public KomgaPageableDto<KomgaCollectionDto> getCollections(int page, int size, boolean unpaged) {
log.debug("Getting collections, page: {}, size: {}, unpaged: {}", page, size, unpaged);
List<MagicShelf> magicShelves = magicShelfService.getUserShelves();
log.debug("Found {} magic shelves", magicShelves.size());
// Convert to collection DTOs - for now, series count is 0 since we don't have
// the series filter implementation
List<KomgaCollectionDto> allCollections = magicShelves.stream()
.map(shelf -> komgaMapper.toKomgaCollectionDto(shelf, 0))
.sorted(Comparator.comparing(KomgaCollectionDto::getName))
.collect(Collectors.toList());
log.debug("Mapped to {} collection DTOs", allCollections.size());
// Handle unpaged mode
int totalElements = allCollections.size();
List<KomgaCollectionDto> content;
int actualPage;
int actualSize;
int totalPages;
if (unpaged) {
content = allCollections;
actualPage = 0;
actualSize = totalElements;
totalPages = totalElements > 0 ? 1 : 0;
} else {
// Paginate
totalPages = (int) Math.ceil((double) totalElements / size);
int fromIndex = Math.min(page * size, totalElements);
int toIndex = Math.min(fromIndex + size, totalElements);
content = allCollections.subList(fromIndex, toIndex);
actualPage = page;
actualSize = size;
}
return KomgaPageableDto.<KomgaCollectionDto>builder()
.content(content)
.number(actualPage)
.size(actualSize)
.numberOfElements(content.size())
.totalElements(totalElements)
.totalPages(totalPages)
.first(actualPage == 0)
.last(totalElements == 0 || actualPage >= totalPages - 1)
.empty(content.isEmpty())
.build();
}
public Resource getBookPageImage(Long bookId, Integer pageNumber, boolean convertToPng) throws IOException {
log.debug("Getting page {} from book {} (convert to PNG: {})", pageNumber, bookId, convertToPng);
BookEntity book = bookRepository.findById(bookId)
.orElseThrow(() -> new RuntimeException("Book not found: " + bookId));
boolean isPDF = book.getPrimaryBookFile().getBookType() == BookFileType.PDF;
// Stream the page to a ByteArrayOutputStream
// streamPageImage will throw if page does not exist
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
// Make sure pages are cached
if (isPDF) {
cbxReaderService.getAvailablePages(bookId);
cbxReaderService.streamPageImage(bookId, pageNumber, outputStream);
} else {
pdfReaderService.getAvailablePages(bookId);
pdfReaderService.streamPageImage(bookId, pageNumber, outputStream);
}
byte[] imageData = outputStream.toByteArray();
// If conversion to PNG is requested, convert the image
if (convertToPng) {
imageData = convertImageToPng(imageData);
}
return new ByteArrayResource(imageData);
}
private byte[] convertImageToPng(byte[] imageData) throws IOException {
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(imageData);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
BufferedImage image = ImageIO.read(inputStream);
if (image == null) {
throw new IOException("Failed to read image data");
}
ImageIO.write(image, "png", outputStream);
return outputStream.toByteArray();
}
}
}

View File

@@ -79,4 +79,8 @@ public class OpdsUserV2Service {
user.setSortOrder(request.sortOrder());
return mapper.toDto(opdsUserV2Repository.save(user));
}
public OpdsUserV2Entity findByUsername(String username) {
return opdsUserV2Repository.findByUsername(username).orElse(null);
}
}

View File

@@ -0,0 +1,10 @@
INSERT INTO app_settings (name, val)
SELECT 'komga_group_unknown', 'true'
WHERE NOT EXISTS (
SELECT 1 FROM app_settings WHERE name = 'komga_group_unknown'
);
INSERT INTO app_settings (name, val)
SELECT 'komga_api_enabled', 'false'
WHERE NOT EXISTS (
SELECT 1 FROM app_settings WHERE name = 'komga_api_enabled'
);

View File

@@ -0,0 +1,147 @@
package com.adityachandel.booklore.config;
import com.adityachandel.booklore.context.KomgaCleanContext;
import com.adityachandel.booklore.model.dto.komga.KomgaBookMetadataDto;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class KomgaCleanFilterTest {
private ObjectMapper objectMapper;
@BeforeEach
void setup() {
// Create ObjectMapper with our custom configuration
JacksonConfig config = new JacksonConfig();
objectMapper = config.komgaCleanObjectMapper(new org.springframework.http.converter.json.Jackson2ObjectMapperBuilder());
}
@AfterEach
void cleanup() {
KomgaCleanContext.clear();
}
@Test
void shouldExcludeLockFieldsInCleanMode() throws Exception {
// Given: Clean mode is enabled
KomgaCleanContext.setCleanMode(true);
KomgaBookMetadataDto metadata = KomgaBookMetadataDto.builder()
.title("Test Book")
.titleLock(true)
.summary("Test Summary")
.summaryLock(false)
.build();
// When: Serializing to JSON
String json = objectMapper.writeValueAsString(metadata);
Map<String, Object> result = objectMapper.readValue(json, Map.class);
// Then: Lock fields should be excluded
assertThat(result).containsKey("title");
assertThat(result).containsKey("summary");
assertThat(result).doesNotContainKey("titleLock");
assertThat(result).doesNotContainKey("summaryLock");
}
@Test
void shouldExcludeNullValuesInCleanMode() throws Exception {
// Given: Clean mode is enabled
KomgaCleanContext.setCleanMode(true);
KomgaBookMetadataDto metadata = KomgaBookMetadataDto.builder()
.title("Test Book")
.summary(null) // Null value
.number("1")
.releaseDate(null) // Null value
.build();
// When: Serializing to JSON
String json = objectMapper.writeValueAsString(metadata);
Map<String, Object> result = objectMapper.readValue(json, Map.class);
// Then: Null values should be excluded
assertThat(result).containsKey("title");
assertThat(result).containsKey("number");
assertThat(result).doesNotContainKey("summary");
assertThat(result).doesNotContainKey("releaseDate");
}
@Test
void shouldExcludeEmptyArraysInCleanMode() throws Exception {
// Given: Clean mode is enabled
KomgaCleanContext.setCleanMode(true);
KomgaBookMetadataDto metadata = KomgaBookMetadataDto.builder()
.title("Test Book")
.authors(new ArrayList<>()) // Empty list
.tags(new ArrayList<>()) // Empty list
.build();
// When: Serializing to JSON
String json = objectMapper.writeValueAsString(metadata);
Map<String, Object> result = objectMapper.readValue(json, Map.class);
// Then: Empty arrays should be excluded
assertThat(result).containsKey("title");
assertThat(result).doesNotContainKey("authors");
assertThat(result).doesNotContainKey("tags");
}
@Test
void shouldIncludeNonEmptyArraysInCleanMode() throws Exception {
// Given: Clean mode is enabled
KomgaCleanContext.setCleanMode(true);
List<String> tags = new ArrayList<>();
tags.add("fiction");
tags.add("adventure");
KomgaBookMetadataDto metadata = KomgaBookMetadataDto.builder()
.title("Test Book")
.tags(tags) // Non-empty list
.build();
// When: Serializing to JSON
String json = objectMapper.writeValueAsString(metadata);
Map<String, Object> result = objectMapper.readValue(json, Map.class);
// Then: Non-empty arrays should be included
assertThat(result).containsKey("title");
assertThat(result).containsKey("tags");
assertThat((List<?>) result.get("tags")).hasSize(2);
}
@Test
void shouldIncludeAllFieldsWhenCleanModeDisabled() throws Exception {
// Given: Clean mode is disabled (default)
KomgaCleanContext.setCleanMode(false);
KomgaBookMetadataDto metadata = KomgaBookMetadataDto.builder()
.title("Test Book")
.titleLock(true)
.summary(null) // Null value
.summaryLock(false)
.authors(new ArrayList<>()) // Empty list
.build();
// When: Serializing to JSON
String json = objectMapper.writeValueAsString(metadata);
Map<String, Object> result = objectMapper.readValue(json, Map.class);
// Then: Lock fields and empty arrays should be included
assertThat(result).containsKey("title");
assertThat(result).containsKey("titleLock");
assertThat(result).containsKey("summaryLock");
assertThat(result).containsKey("authors");
// Note: null values are excluded by @JsonInclude(JsonInclude.Include.NON_NULL) regardless
}
}

View File

@@ -0,0 +1,84 @@
package com.adityachandel.booklore.config;
import com.adityachandel.booklore.context.KomgaCleanContext;
import com.adityachandel.booklore.model.dto.komga.KomgaSeriesMetadataDto;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Demonstrates the JSON output differences with and without clean mode.
*/
class KomgaCleanModeDemo {
private ObjectMapper objectMapper;
@BeforeEach
void setup() {
JacksonConfig config = new JacksonConfig();
objectMapper = config.komgaCleanObjectMapper(new org.springframework.http.converter.json.Jackson2ObjectMapperBuilder());
}
@AfterEach
void cleanup() {
KomgaCleanContext.clear();
}
@Test
void demonstrateCleanModeEffect() throws Exception {
// Create a sample series metadata
KomgaSeriesMetadataDto metadata = KomgaSeriesMetadataDto.builder()
.status("ONGOING")
.statusLock(false)
.title("My Awesome Series")
.titleLock(false)
.titleSort("My Awesome Series")
.titleSortLock(false)
.summary(null) // No summary available
.summaryLock(false)
.readingDirection("LEFT_TO_RIGHT")
.readingDirectionLock(false)
.publisher(null) // No publisher available
.publisherLock(false)
.language(null) // No language specified
.languageLock(false)
.genres(new ArrayList<>())
.genresLock(false)
.tags(new ArrayList<>())
.tagsLock(false)
.totalBookCount(10)
.totalBookCountLock(false)
.ageRatingLock(false)
.build();
// Test without clean mode
KomgaCleanContext.setCleanMode(false);
String normalJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(metadata);
System.out.println("=== WITHOUT CLEAN MODE ===");
System.out.println(normalJson);
System.out.println("JSON size: " + normalJson.length() + " bytes\n");
// Test with clean mode
KomgaCleanContext.setCleanMode(true);
String cleanJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(metadata);
System.out.println("=== WITH CLEAN MODE (clean=true) ===");
System.out.println(cleanJson);
System.out.println("JSON size: " + cleanJson.length() + " bytes\n");
// Verify size difference
System.out.println("Size reduction: " + (normalJson.length() - cleanJson.length()) + " bytes");
System.out.println("Percentage smaller: " + String.format("%.1f", 100.0 * (normalJson.length() - cleanJson.length()) / normalJson.length()) + "%");
// Assert that clean mode produces smaller output
assertThat(cleanJson.length()).isLessThan(normalJson.length());
// Assert that clean JSON doesn't contain "Lock" fields
assertThat(cleanJson).doesNotContain("Lock");
}
}

View File

@@ -0,0 +1,126 @@
package com.adityachandel.booklore.interceptor;
import com.adityachandel.booklore.context.KomgaCleanContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
class KomgaCleanInterceptorTest {
private KomgaCleanInterceptor interceptor;
@Mock
private HttpServletRequest request;
@Mock
private HttpServletResponse response;
@BeforeEach
void setup() {
MockitoAnnotations.openMocks(this);
interceptor = new KomgaCleanInterceptor();
}
@AfterEach
void cleanup() {
KomgaCleanContext.clear();
}
@Test
void shouldEnableCleanModeWithParameterWithoutValue() throws Exception {
// Given: Request with ?clean (no value)
when(request.getRequestURI()).thenReturn("/komga/api/v1/series");
when(request.getParameter("clean")).thenReturn(""); // Empty string means parameter present without value
// When: Interceptor processes request
interceptor.preHandle(request, response, new Object());
// Then: Clean mode should be enabled
assertThat(KomgaCleanContext.isCleanMode()).isTrue();
}
@Test
void shouldEnableCleanModeWithParameterSetToTrue() throws Exception {
// Given: Request with ?clean=true
when(request.getRequestURI()).thenReturn("/komga/api/v1/series");
when(request.getParameter("clean")).thenReturn("true");
// When: Interceptor processes request
interceptor.preHandle(request, response, new Object());
// Then: Clean mode should be enabled
assertThat(KomgaCleanContext.isCleanMode()).isTrue();
}
@Test
void shouldEnableCleanModeWithParameterSetToTrueCaseInsensitive() throws Exception {
// Given: Request with ?clean=TRUE
when(request.getRequestURI()).thenReturn("/komga/api/v1/books/123");
when(request.getParameter("clean")).thenReturn("TRUE");
// When: Interceptor processes request
interceptor.preHandle(request, response, new Object());
// Then: Clean mode should be enabled
assertThat(KomgaCleanContext.isCleanMode()).isTrue();
}
@Test
void shouldNotEnableCleanModeWhenParameterAbsent() throws Exception {
// Given: Request without clean parameter
when(request.getRequestURI()).thenReturn("/komga/api/v1/series");
when(request.getParameter("clean")).thenReturn(null);
// When: Interceptor processes request
interceptor.preHandle(request, response, new Object());
// Then: Clean mode should not be enabled
assertThat(KomgaCleanContext.isCleanMode()).isFalse();
}
@Test
void shouldNotEnableCleanModeWhenParameterSetToFalse() throws Exception {
// Given: Request with ?clean=false
when(request.getRequestURI()).thenReturn("/komga/api/v1/series");
when(request.getParameter("clean")).thenReturn("false");
// When: Interceptor processes request
interceptor.preHandle(request, response, new Object());
// Then: Clean mode should not be enabled
assertThat(KomgaCleanContext.isCleanMode()).isFalse();
}
@Test
void shouldNotApplyToNonKomgaEndpoints() throws Exception {
// Given: Request to non-Komga endpoint with clean parameter
when(request.getRequestURI()).thenReturn("/api/v1/books");
when(request.getParameter("clean")).thenReturn("true");
// When: Interceptor processes request
interceptor.preHandle(request, response, new Object());
// Then: Clean mode should not be enabled
assertThat(KomgaCleanContext.isCleanMode()).isFalse();
}
@Test
void shouldClearContextAfterCompletion() throws Exception {
// Given: Clean mode is enabled
KomgaCleanContext.setCleanMode(true);
assertThat(KomgaCleanContext.isCleanMode()).isTrue();
// When: After completion is called
interceptor.afterCompletion(request, response, new Object(), null);
// Then: Context should be cleared
assertThat(KomgaCleanContext.isCleanMode()).isFalse();
}
}

View File

@@ -0,0 +1,247 @@
package com.adityachandel.booklore.mapper.komga;
import com.adityachandel.booklore.context.KomgaCleanContext;
import com.adityachandel.booklore.model.dto.komga.KomgaBookDto;
import com.adityachandel.booklore.model.dto.komga.KomgaSeriesDto;
import com.adityachandel.booklore.model.dto.settings.AppSettings;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookFileEntity;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class KomgaMapperTest {
@Mock
private AppSettingService appSettingService;
@InjectMocks
private KomgaMapper mapper;
@AfterEach
void cleanup() {
// Always clean up the context after each test
KomgaCleanContext.clear();
}
@BeforeEach
void setUp() {
// Mock app settings for all tests
AppSettings appSettings = new AppSettings();
appSettings.setKomgaGroupUnknown(true);
when(appSettingService.getAppSettings()).thenReturn(appSettings);
}
@Test
void shouldHandleNullPageCountInMetadata() {
// Given: A book with metadata that has null pageCount
LibraryEntity library = new LibraryEntity();
library.setId(1L);
BookMetadataEntity metadata = BookMetadataEntity.builder()
.title("Test Book")
.seriesName("Test Series")
.pageCount(null) // Explicitly null
.build();
BookEntity book = new BookEntity();
book.setId(100L);
book.setLibrary(library);
book.setMetadata(metadata);
book.setAddedOn(Instant.now());
BookFileEntity pdf = new BookFileEntity();
pdf.setId(100L);
pdf.setBook(book);
pdf.setFileSubPath("author/title");
pdf.setFileName("test-book.pdf");
pdf.setBookType(BookFileType.PDF);
pdf.setBookFormat(true);
book.setBookFiles(List.of(pdf));
// When: Converting to DTO
KomgaBookDto dto = mapper.toKomgaBookDto(book);
// Then: Should not throw NPE and pageCount should default to 0
assertThat(dto).isNotNull();
assertThat(dto.getMedia()).isNotNull();
assertThat(dto.getMedia().getPagesCount()).isEqualTo(0);
}
@Test
void shouldHandleNullMetadata() {
// Given: A book with null metadata
LibraryEntity library = new LibraryEntity();
library.setId(1L);
BookEntity book = new BookEntity();
book.setId(100L);
book.setLibrary(library);
book.setMetadata(null); // Null metadata
book.setAddedOn(Instant.now());
BookFileEntity pdf = new BookFileEntity();
pdf.setId(100L);
pdf.setBook(book);
pdf.setFileSubPath("author/title");
pdf.setFileName("test-book.pdf");
pdf.setBookType(BookFileType.PDF);
pdf.setBookFormat(true);
book.setBookFiles(List.of(pdf));
// When: Converting to DTO
KomgaBookDto dto = mapper.toKomgaBookDto(book);
// Then: Should not throw NPE and pageCount should default to 0
assertThat(dto).isNotNull();
assertThat(dto.getMedia()).isNotNull();
assertThat(dto.getMedia().getPagesCount()).isEqualTo(0);
}
@Test
void shouldHandleValidPageCount() {
// Given: A book with metadata that has valid pageCount
LibraryEntity library = new LibraryEntity();
library.setId(1L);
BookMetadataEntity metadata = BookMetadataEntity.builder()
.title("Test Book")
.seriesName("Test Series")
.pageCount(250)
.build();
BookEntity book = new BookEntity();
book.setId(100L);
book.setLibrary(library);
book.setMetadata(metadata);
book.setAddedOn(Instant.now());
BookFileEntity pdf = new BookFileEntity();
pdf.setId(100L);
pdf.setBook(book);
pdf.setFileSubPath("author/title");
pdf.setFileName("test-book.pdf");
pdf.setBookType(BookFileType.PDF);
pdf.setBookFormat(true);
book.setBookFiles(List.of(pdf));
// When: Converting to DTO
KomgaBookDto dto = mapper.toKomgaBookDto(book);
// Then: Should use the actual pageCount
assertThat(dto).isNotNull();
assertThat(dto.getMedia()).isNotNull();
assertThat(dto.getMedia().getPagesCount()).isEqualTo(250);
}
@Test
void shouldReturnNullForEmptyFieldsInCleanMode() {
// Given: Clean mode is enabled
KomgaCleanContext.setCleanMode(true);
LibraryEntity library = new LibraryEntity();
library.setId(1L);
List<BookEntity> books = new ArrayList<>();
BookEntity book = new BookEntity();
book.setId(100L);
book.setLibrary(library);
book.setAddedOn(Instant.now());
BookFileEntity pdf = new BookFileEntity();
pdf.setId(100L);
pdf.setBook(book);
pdf.setFileSubPath("author/title");
pdf.setFileName("test-book.pdf");
pdf.setBookType(BookFileType.PDF);
pdf.setBookFormat(true);
book.setBookFiles(List.of(pdf));
// Book with metadata but empty fields
BookMetadataEntity metadata = BookMetadataEntity.builder()
.title("Test Book")
.seriesName("Test Series")
.description(null)
.language(null)
.publisher(null)
.build();
book.setMetadata(metadata);
books.add(book);
// When: Converting to series DTO
KomgaSeriesDto seriesDto = mapper.toKomgaSeriesDto("Test Series", 1L, books);
// Then: Empty fields should be null (not empty strings) in clean mode
assertThat(seriesDto).isNotNull();
assertThat(seriesDto.getMetadata()).isNotNull();
assertThat(seriesDto.getMetadata().getSummary()).isNull();
assertThat(seriesDto.getMetadata().getLanguage()).isNull();
assertThat(seriesDto.getMetadata().getPublisher()).isNull();
}
@Test
void shouldReturnDefaultValuesWhenCleanModeDisabled() {
// Given: Clean mode is disabled (default)
KomgaCleanContext.setCleanMode(false);
LibraryEntity library = new LibraryEntity();
library.setId(1L);
List<BookEntity> books = new ArrayList<>();
BookEntity book = new BookEntity();
book.setId(100L);
book.setLibrary(library);
book.setAddedOn(Instant.now());
BookFileEntity pdf = new BookFileEntity();
pdf.setId(100L);
pdf.setBook(book);
pdf.setFileSubPath("author/title");
pdf.setFileName("test-book.pdf");
pdf.setBookType(BookFileType.PDF);
pdf.setBookFormat(true);
book.setBookFiles(List.of(pdf));
// Book with metadata but empty fields
BookMetadataEntity metadata = BookMetadataEntity.builder()
.title("Test Book")
.seriesName("Test Series")
.description(null)
.language(null)
.publisher(null)
.build();
book.setMetadata(metadata);
books.add(book);
// When: Converting to series DTO
KomgaSeriesDto seriesDto = mapper.toKomgaSeriesDto("Test Series", 1L, books);
// Then: Empty fields should have default values (not null)
assertThat(seriesDto).isNotNull();
assertThat(seriesDto.getMetadata()).isNotNull();
assertThat(seriesDto.getMetadata().getSummary()).isEqualTo("");
assertThat(seriesDto.getMetadata().getLanguage()).isEqualTo("en");
assertThat(seriesDto.getMetadata().getPublisher()).isEqualTo("");
}
}

View File

@@ -0,0 +1,249 @@
package com.adityachandel.booklore.service.komga;
import com.adityachandel.booklore.mapper.komga.KomgaMapper;
import com.adityachandel.booklore.model.dto.komga.KomgaBookDto;
import com.adityachandel.booklore.model.dto.komga.KomgaPageDto;
import com.adityachandel.booklore.model.dto.komga.KomgaPageableDto;
import com.adityachandel.booklore.model.dto.komga.KomgaSeriesDto;
import com.adityachandel.booklore.model.dto.settings.AppSettings;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookFileEntity;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.repository.LibraryRepository;
import com.adityachandel.booklore.service.MagicShelfService;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import com.adityachandel.booklore.service.reader.CbxReaderService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class KomgaServiceTest {
@Mock
private BookRepository bookRepository;
@Mock
private LibraryRepository libraryRepository;
@Mock
private KomgaMapper komgaMapper;
@Mock
private MagicShelfService magicShelfService;
@Mock
private CbxReaderService cbxReaderService;
@Mock
private AppSettingService appSettingService;
@InjectMocks
private KomgaService komgaService;
private LibraryEntity library;
private List<BookEntity> seriesBooks;
@BeforeEach
void setUp() {
library = new LibraryEntity();
library.setId(1L);
// Mock app settings (lenient because not all tests use this)
AppSettings appSettings = new AppSettings();
appSettings.setKomgaGroupUnknown(true);
lenient().when(appSettingService.getAppSettings()).thenReturn(appSettings);
// Create multiple books for testing pagination
seriesBooks = new ArrayList<>();
for (int i = 1; i <= 50; i++) {
BookMetadataEntity metadata = BookMetadataEntity.builder()
.title("Book " + i)
.seriesName("Test Series")
.seriesNumber((float) i)
.pageCount(null) // Test null pageCount
.build();
BookEntity book = new BookEntity();
book.setId((long) i);
book.setLibrary(library);
book.setMetadata(metadata);
book.setAddedOn(Instant.now());
BookFileEntity pdf = new BookFileEntity();
pdf.setId((long) i);
pdf.setBook(book);
pdf.setFileSubPath("author/title");
pdf.setFileName("book-" + i + ".pdf");
pdf.setBookType(BookFileType.PDF);
pdf.setBookFormat(true);
book.setBookFiles(List.of(pdf));
seriesBooks.add(book);
}
}
@Test
void shouldReturnAllBooksWhenUnpagedIsTrue() {
// Given
when(bookRepository.findAllWithMetadataByLibraryId(anyLong())).thenReturn(seriesBooks);
// Mock mapper.getBookSeriesName to return "test-series" for all books
for (BookEntity book : seriesBooks) {
when(komgaMapper.getBookSeriesName(book)).thenReturn("test-series");
}
// Mock the mapper to return DTOs
for (BookEntity book : seriesBooks) {
KomgaBookDto dto = KomgaBookDto.builder()
.id(book.getId().toString())
.name(book.getMetadata().getTitle())
.build();
when(komgaMapper.toKomgaBookDto(book)).thenReturn(dto);
}
// When: Request with unpaged=true
KomgaPageableDto<KomgaBookDto> result = komgaService.getBooksBySeries("1-test-series", 0, 20, true);
// Then: Should return all 50 books
assertThat(result).isNotNull();
assertThat(result.getContent()).hasSize(50);
assertThat(result.getTotalElements()).isEqualTo(50);
assertThat(result.getTotalPages()).isEqualTo(1);
assertThat(result.getSize()).isEqualTo(50);
assertThat(result.getNumber()).isEqualTo(0);
}
@Test
void shouldReturnPagedBooksWhenUnpagedIsFalse() {
// Given
when(bookRepository.findAllWithMetadataByLibraryId(anyLong())).thenReturn(seriesBooks);
// Mock mapper.getBookSeriesName to return "test-series" for all books
for (BookEntity book : seriesBooks) {
when(komgaMapper.getBookSeriesName(book)).thenReturn("test-series");
}
// Mock the mapper to return DTOs (only for the books that will be used)
for (int i = 0; i < 20; i++) {
BookEntity book = seriesBooks.get(i);
KomgaBookDto dto = KomgaBookDto.builder()
.id(book.getId().toString())
.name(book.getMetadata().getTitle())
.build();
when(komgaMapper.toKomgaBookDto(book)).thenReturn(dto);
}
// When: Request with unpaged=false and page size 20
KomgaPageableDto<KomgaBookDto> result = komgaService.getBooksBySeries("1-test-series", 0, 20, false);
// Then: Should return first page with 20 books
assertThat(result).isNotNull();
assertThat(result.getContent()).hasSize(20);
assertThat(result.getTotalElements()).isEqualTo(50);
assertThat(result.getTotalPages()).isEqualTo(3);
assertThat(result.getSize()).isEqualTo(20);
assertThat(result.getNumber()).isEqualTo(0);
}
@Test
void shouldHandleNullPageCountInGetBookPages() {
// Given: Book with null pageCount
BookMetadataEntity metadata = BookMetadataEntity.builder()
.title("Test Book")
.pageCount(null)
.build();
BookEntity book = new BookEntity();
book.setId(100L);
book.setMetadata(metadata);
when(bookRepository.findById(100L)).thenReturn(Optional.of(book));
// When: Get book pages
List<KomgaPageDto> pages = komgaService.getBookPages(100L);
// Then: Should return empty list without throwing NPE
assertThat(pages).isNotNull();
assertThat(pages).isEmpty();
}
@Test
void shouldReturnCorrectPagesWhenPageCountIsValid() {
// Given: Book with valid pageCount
BookMetadataEntity metadata = BookMetadataEntity.builder()
.title("Test Book")
.pageCount(5)
.build();
BookEntity book = new BookEntity();
book.setId(100L);
book.setMetadata(metadata);
when(bookRepository.findById(100L)).thenReturn(Optional.of(book));
// When: Get book pages
List<KomgaPageDto> pages = komgaService.getBookPages(100L);
// Then: Should return 5 pages
assertThat(pages).isNotNull();
assertThat(pages).hasSize(5);
assertThat(pages.get(0).getNumber()).isEqualTo(1);
assertThat(pages.get(4).getNumber()).isEqualTo(5);
}
@Test
void shouldGetAllSeriesOptimized() {
// Given: Mock the optimized repository method
List<String> seriesNames = List.of("Series A", "Series B", "Series C");
when(bookRepository.findDistinctSeriesNamesGroupedByLibraryId(anyLong(), anyString()))
.thenReturn(seriesNames);
// Mock books for the first page (Series A and Series B only)
List<BookEntity> seriesABooks = List.of(seriesBooks.get(0), seriesBooks.get(1));
List<BookEntity> seriesBBooks = List.of(seriesBooks.get(2), seriesBooks.get(3));
when(bookRepository.findBooksBySeriesNameGroupedByLibraryId("Series A", 1L, "Unknown Series"))
.thenReturn(seriesABooks);
when(bookRepository.findBooksBySeriesNameGroupedByLibraryId("Series B", 1L, "Unknown Series"))
.thenReturn(seriesBBooks);
when(komgaMapper.getUnknownSeriesName()).thenReturn("Unknown Series");
when(komgaMapper.toKomgaSeriesDto(eq("Series A"), anyLong(), any()))
.thenReturn(KomgaSeriesDto.builder().id("1-series-a").name("Series A").booksCount(2).build());
when(komgaMapper.toKomgaSeriesDto(eq("Series B"), anyLong(), any()))
.thenReturn(KomgaSeriesDto.builder().id("1-series-b").name("Series B").booksCount(2).build());
// When: Request first page with size 2
KomgaPageableDto<KomgaSeriesDto> result = komgaService.getAllSeries(1L, 0, 2, false);
// Then: Should return only 2 series (not all 3)
assertThat(result).isNotNull();
assertThat(result.getContent()).hasSize(2);
assertThat(result.getTotalElements()).isEqualTo(3);
assertThat(result.getTotalPages()).isEqualTo(2);
assertThat(result.getNumber()).isEqualTo(0);
assertThat(result.getFirst()).isTrue();
assertThat(result.getLast()).isFalse();
// Verify that only books for Series A and B were loaded (optimization check)
verify(bookRepository, never()).findAllWithMetadataByLibraryId(anyLong());
verify(bookRepository, never()).findAllWithMetadata();
}
}

View File

@@ -40,6 +40,93 @@
</div>
</div>
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-power-off"></i>
Komga API
</h3>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Komga API Enabled</label>
<p-toggleswitch
[(ngModel)]="komgaApiEnabled"
(onChange)="toggleKomgaApi()">
</p-toggleswitch>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Enable the Komga-compatible API to use Komga clients (Tachiyomi, Komelia, etc.) with Booklore. Uses the same OPDS user accounts for authentication.
</p>
</div>
</div>
@if (komgaApiEnabled) {
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Group Unknown Series</label>
<p-toggleswitch
[(ngModel)]="komgaGroupUnknown"
(onChange)="toggleKomgaGroupUnknown()">
</p-toggleswitch>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
When enabled, books without a series will be grouped under "Unknown Series". When disabled, each book without a series will appear as its own individual series.
</p>
</div>
</div>
}
</div>
</div>
@if (komgaApiEnabled) {
<div class="preferences-section">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-link"></i>
Komga API Endpoint
</h3>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Komga Base URL</label>
<div class="input-group">
<input
id="komga-endpoint-url"
class="endpoint-input"
fluid
type="text"
pInputText
[value]="komgaEndpoint"
readonly/>
<p-button
icon="pi pi-copy"
severity="info"
outlined
size="small"
(onClick)="copyKomgaEndpoint()">
</p-button>
</div>
</div>
<p class="setting-description">
<i class="pi pi-info-circle"></i>
Use this URL as the server address in Komga-compatible clients. The API is available at <code>/komga/api/v1/*</code> and uses OPDS user credentials.
</p>
</div>
</div>
</div>
</div>
}
@if (opdsEnabled) {
<div class="preferences-section">
<div class="section-header">
@@ -310,4 +397,4 @@
</div>
</div>
}
</div>
</div>

View File

@@ -42,7 +42,10 @@ import {Select} from 'primeng/select';
export class OpdsSettings implements OnInit, OnDestroy {
opdsEndpoint = `${API_CONFIG.BASE_URL}/api/v1/opds`;
komgaEndpoint = `${API_CONFIG.BASE_URL}/komga`;
opdsEnabled = false;
komgaApiEnabled = false;
komgaGroupUnknown = true;
private opdsService = inject(OpdsService);
private confirmationService = inject(ConfirmationService);
@@ -102,7 +105,9 @@ export class OpdsSettings implements OnInit, OnDestroy {
)
.subscribe(settings => {
this.opdsEnabled = settings.opdsServerEnabled ?? false;
if (this.opdsEnabled) {
this.komgaApiEnabled = settings.komgaApiEnabled ?? false;
this.komgaGroupUnknown = settings.komgaGroupUnknown ?? true;
if (this.opdsEnabled || this.komgaApiEnabled) {
this.loadUsers();
} else {
this.loading = false;
@@ -182,13 +187,42 @@ export class OpdsSettings implements OnInit, OnDestroy {
toggleOpdsServer(): void {
this.saveSetting(AppSettingKey.OPDS_SERVER_ENABLED, this.opdsEnabled);
if (this.opdsEnabled) {
if (this.opdsEnabled || this.komgaApiEnabled) {
this.loadUsers();
} else {
this.users = [];
}
}
toggleKomgaApi(): void {
this.saveKomgaSetting(AppSettingKey.KOMGA_API_ENABLED, this.komgaApiEnabled);
if (this.opdsEnabled || this.komgaApiEnabled) {
this.loadUsers();
} else {
this.users = [];
}
}
copyKomgaEndpoint(): void {
navigator.clipboard.writeText(this.komgaEndpoint).then(() => {
this.showMessage('success', 'Copied', 'Komga API endpoint copied to clipboard');
});
}
toggleKomgaGroupUnknown(): void {
this.appSettingsService.saveSettings([{key: AppSettingKey.KOMGA_GROUP_UNKNOWN, newValue: this.komgaGroupUnknown}]).subscribe({
next: () => {
const successMessage = (this.komgaGroupUnknown === true)
? 'Books without series will be grouped under "Unknown Series".'
: 'Books without series will appear as individual series.';
this.showMessage('success', 'Settings Saved', successMessage);
},
error: () => {
this.showMessage('error', 'Error', 'There was an error saving the settings.');
}
});
}
private saveSetting(key: string, value: unknown): void {
this.appSettingsService.saveSettings([{key, newValue: value}]).subscribe({
next: () => {
@@ -203,6 +237,20 @@ export class OpdsSettings implements OnInit, OnDestroy {
});
}
private saveKomgaSetting(key: string, value: unknown): void {
this.appSettingsService.saveSettings([{key, newValue: value}]).subscribe({
next: () => {
const successMessage = (value === true)
? 'Komga API Enabled.'
: 'Komga API Disabled.';
this.showMessage('success', 'Settings Saved', successMessage);
},
error: () => {
this.showMessage('error', 'Error', 'There was an error saving the settings.');
}
});
}
private resetCreateUserDialog(): void {
this.showCreateUserDialog = false;
this.newUser = {username: '', password: '', sortOrder: 'RECENT'};
@@ -254,4 +302,4 @@ export class OpdsSettings implements OnInit, OnDestroy {
this.destroy$.next();
this.destroy$.complete();
}
}
}

View File

@@ -145,6 +145,8 @@ export interface AppSettings {
libraryMetadataRefreshOptions: MetadataRefreshOptions[];
uploadPattern: string;
opdsServerEnabled: boolean;
komgaApiEnabled: boolean;
komgaGroupUnknown: boolean;
remoteAuthEnabled: boolean;
oidcEnabled: boolean;
oidcProviderDetails: OidcProviderDetails;
@@ -187,6 +189,8 @@ export enum AppSettingKey {
LIBRARY_METADATA_REFRESH_OPTIONS = 'LIBRARY_METADATA_REFRESH_OPTIONS',
UPLOAD_FILE_PATTERN = 'UPLOAD_FILE_PATTERN',
OPDS_SERVER_ENABLED = 'OPDS_SERVER_ENABLED',
KOMGA_API_ENABLED = 'KOMGA_API_ENABLED',
KOMGA_GROUP_UNKNOWN = 'KOMGA_GROUP_UNKNOWN',
OIDC_ENABLED = 'OIDC_ENABLED',
OIDC_PROVIDER_DETAILS = 'OIDC_PROVIDER_DETAILS',
OIDC_AUTO_PROVISION_DETAILS = 'OIDC_AUTO_PROVISION_DETAILS',

View File

@@ -21,6 +21,8 @@ describe('AppSettingsService', () => {
libraryMetadataRefreshOptions: [],
uploadPattern: '',
opdsServerEnabled: false,
komgaApiEnabled: false,
komgaGroupUnknown: false,
remoteAuthEnabled: false,
oidcEnabled: true,
oidcProviderDetails: {
@@ -391,6 +393,8 @@ describe('AppSettingsService - API Contract Tests', () => {
libraryMetadataRefreshOptions: [],
uploadPattern: '',
opdsServerEnabled: false,
komgaApiEnabled: false,
komgaGroupUnknown: false,
remoteAuthEnabled: false,
oidcEnabled: true,
oidcProviderDetails: {

112
docs/Komga-API.md Normal file
View File

@@ -0,0 +1,112 @@
# Komga API Support
Booklore provides a Komga-compatible API that allows you to use Komga clients (like Tachiyomi, Tachidesk, Komelia, etc.) to access your Booklore library.
## Features
The Komga API implementation in Booklore provides the following endpoints:
### Libraries
- `GET /api/v1/libraries` - List all libraries
- `GET /api/v1/libraries/{libraryId}` - Get library details
### Series
- `GET /api/v1/series` - List series (supports pagination and library filtering)
- `GET /api/v1/series/{seriesId}` - Get series details
- `GET /api/v1/series/{seriesId}/books` - List books in a series
- `GET /api/v1/series/{seriesId}/thumbnail` - Get series thumbnail
### Books
- `GET /api/v1/books` - List all books (supports pagination and library filtering)
- `GET /api/v1/books/{bookId}` - Get book details
- `GET /api/v1/books/{bookId}/pages` - Get book pages metadata
- `GET /api/v1/books/{bookId}/pages/{pageNumber}` - Get book page image
- `GET /api/v1/books/{bookId}/file` - Download book file
- `GET /api/v1/books/{bookId}/thumbnail` - Get book thumbnail
### Users
- `GET /api/v2/users/me` - Get current user details
## Data Model Mapping
Booklore organizes books differently than Komga:
- **Komga**: Libraries → Series → Books
- **Booklore**: Libraries → Books (with optional series metadata)
The Komga API layer automatically creates virtual "series" by grouping books with the same series name in their metadata. Books without a series name are grouped under "Unknown Series".
## Enabling the Komga API
1. Navigate to **Settings** in Booklore
2. Find the **Komga API** section
3. Toggle **Enable Komga API** to ON
4. Click **Save**
## Authentication
The Komga API uses the same OPDS user accounts for authentication. To access the Komga API:
1. Create an OPDS user account in Booklore settings
2. Use those credentials when configuring your Komga client
Authentication uses HTTP Basic Auth, the same as OPDS.
## Using with Komga Clients
### Tachiyomi / TachiyomiSY / TachiyomiJ2K
1. Install the Tachiyomi app
2. Add a source → Browse → Sources → Komga
3. Configure the source:
- Server URL: `http://your-booklore-server/`
- Username: Your OPDS username
- Password: Your OPDS password
### Komelia
1. Install Komelia
2. Add a server:
- URL: `http://your-booklore-server/`
- Username: Your OPDS username
- Password: Your OPDS password
### Tachidesk
1. Install Tachidesk
2. Add Komga extension
3. Configure:
- Server URL: `http://your-booklore-server/`
- Username: Your OPDS username
- Password: Your OPDS password
## Limitations
- Individual page extraction is not yet implemented; page requests return the book cover
- Read progress tracking from Komga clients is not synchronized with Booklore
- Not all Komga API endpoints are implemented (only the most commonly used ones)
## Troubleshooting
### Cannot connect to server
- Ensure the Komga API is enabled in Booklore settings
- Verify your OPDS credentials are correct
- Check that your server is accessible from the client device
### Books not appearing
- Ensure books have metadata populated, especially series information
- Try refreshing the library in your Komga client
### Authentication failures
- The Komga API uses OPDS user accounts, not your main Booklore account
- Create an OPDS user in the Settings → OPDS section
- Use those credentials in your Komga client
## API Compatibility
The Booklore Komga API aims to be compatible with Komga v1.x API. While not all endpoints are implemented, the core functionality needed for reading and browsing is supported.
For the complete Komga API specification, see: https://github.com/gotson/komga

71
docs/komga-clean-mode.md Normal file
View File

@@ -0,0 +1,71 @@
# Komga API Clean Mode
## Overview
The Komga API now supports a `clean` query parameter that allows clients to receive cleaner, more compact JSON responses.
## Usage
Add the `clean` query parameter to any Komga API endpoint. Both syntaxes are supported:
```
# Using parameter without value
GET /komga/api/v1/series?clean
GET /komga/api/v1/books/123?clean
GET /komga/api/v1/libraries?clean
# Using parameter with explicit true value
GET /komga/api/v1/series?clean=true
GET /komga/api/v1/books/123?clean=true
GET /komga/api/v1/libraries?clean=true
```
## Behavior
When the `clean` parameter is present (either `?clean` or `?clean=true`):
1. **Lock Fields Excluded**: All fields ending with "Lock" (e.g., `titleLock`, `summaryLock`, `authorsLock`) are removed from the response
2. **Null Values Excluded**: All fields with `null` values are removed from the response
3. **Empty Arrays Excluded**: All empty arrays/collections (e.g., empty `genres`, `tags`, `links`) are removed from the response
4. **Metadata Fields**: Metadata fields like `summary`, `language`, and `publisher` that would normally default to empty strings or default values can now be `null` and thus filtered out
## Examples
### Without Clean Mode (default)
```json
{
"title": "My Book",
"titleLock": false,
"summary": "",
"summaryLock": false,
"language": "en",
"languageLock": false,
"publisher": "",
"publisherLock": false,
"genres": [],
"genresLock": false,
"tags": [],
"tagsLock": false
}
```
### With Clean Mode (`?clean` or `?clean=true`)
```json
{
"title": "My Book"
}
```
All the Lock fields, empty strings, and null values are excluded, resulting in a much smaller response.
## Benefits
- **Reduced Payload Size**: Significantly smaller JSON responses, especially important for mobile clients or slow connections
- **Cleaner API**: Removes clutter from responses that clients typically don't need
- **Backward Compatible**: Default behavior remains unchanged; opt-in via query parameter
## Implementation Details
- Works with all Komga API GET endpoints under `/komga/api/**`
- Uses a custom Jackson serializer modifier with ThreadLocal context
- Implemented via Spring interceptor that detects the query parameter
- ThreadLocal is properly cleaned up after each request to prevent memory leaks

View File

@@ -55,6 +55,25 @@ http {
proxy_send_timeout 86400s;
}
# Proxy API requests that start with /komga/ to the backend
# we can't use /api because Komga clients expect /komga/api/v1
location /komga/ {
proxy_pass http://localhost:8080;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Host $host;
# Disable buffering for streaming responses
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding on;
# Set timeouts for long-running connections
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# Proxy WebSocket requests (ws://) to the backend
location /ws {
proxy_pass http://localhost:8080/ws; # Backend WebSocket endpoint