mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-02-17 18:57:40 +01:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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/**");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
112
docs/Komga-API.md
Normal 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
71
docs/komga-clean-mode.md
Normal 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
|
||||
19
nginx.conf
19
nginx.conf
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user