diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/JacksonConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/JacksonConfig.java new file mode 100644 index 000000000..0cd52c39b --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/JacksonConfig.java @@ -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 changeProperties( + com.fasterxml.jackson.databind.SerializationConfig config, + com.fasterxml.jackson.databind.BeanDescription beanDesc, + List beanProperties) { + + return beanProperties.stream() + .map(KomgaCleanBeanPropertyWriter::new) + .collect(Collectors.toList()); + } + }) + ); + + return mapper; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/KomgaCleanBeanPropertyWriter.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/KomgaCleanBeanPropertyWriter.java new file mode 100644 index 000000000..8336c436f --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/KomgaCleanBeanPropertyWriter.java @@ -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); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/WebMvcConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/WebMvcConfig.java index bbcf3c5fb..1cbd047c6 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/WebMvcConfig.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/WebMvcConfig.java @@ -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/**"); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java index 716eaac9b..c5d608c2c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java @@ -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; } -} +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/context/KomgaCleanContext.java b/booklore-api/src/main/java/com/adityachandel/booklore/context/KomgaCleanContext.java new file mode 100644 index 000000000..8b3cd7cfc --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/context/KomgaCleanContext.java @@ -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 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(); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/KomgaController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/KomgaController.java new file mode 100644 index 000000000..a435c5643 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/KomgaController.java @@ -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 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 getAllLibraries() { + List libraries = komgaService.getAllLibraries(); + return writeJson(libraries); + } + + @Operation(summary = "Get library details") + @GetMapping("/v1/libraries/{libraryId}") + public ResponseEntity getLibrary( + @Parameter(description = "Library ID") @PathVariable Long libraryId) { + return writeJson(komgaService.getLibraryById(libraryId)); + } + + // ==================== Series ==================== + + @Operation(summary = "List series") + @GetMapping("/v1/series") + public ResponseEntity 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 result = komgaService.getAllSeries(libraryId, page, size, unpaged); + return writeJson(result); + } + + @Operation(summary = "Get series details") + @GetMapping("/v1/series/{seriesId}") + public ResponseEntity 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 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 getSeriesThumbnail( + @Parameter(description = "Series ID") @PathVariable String seriesId) { + KomgaPageableDto 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 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 result = komgaService.getAllBooks(libraryId, page, size); + return writeJson(result); + } + + @Operation(summary = "Get book details") + @GetMapping("/v1/books/{bookId}") + public ResponseEntity 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 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 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 downloadBook( + @Parameter(description = "Book ID") @PathVariable Long bookId) { + return bookService.downloadBook(bookId); + } + + @Operation(summary = "Get book thumbnail") + @GetMapping("/v1/books/{bookId}/thumbnail") + public ResponseEntity 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 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 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)); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/interceptor/KomgaCleanInterceptor.java b/booklore-api/src/main/java/com/adityachandel/booklore/interceptor/KomgaCleanInterceptor.java new file mode 100644 index 000000000..21472ee07 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/interceptor/KomgaCleanInterceptor.java @@ -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(); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/interceptor/KomgaEnabledInterceptor.java b/booklore-api/src/main/java/com/adityachandel/booklore/interceptor/KomgaEnabledInterceptor.java new file mode 100644 index 000000000..80b1ca978 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/interceptor/KomgaEnabledInterceptor.java @@ -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; + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/komga/KomgaMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/komga/KomgaMapper.java new file mode 100644 index 000000000..3fc2239dc --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/komga/KomgaMapper.java @@ -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 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 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 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 books) { + BookEntity firstBook = books.get(0); + BookMetadataEntity firstMetadata = firstBook.getMetadata(); + + List genres = new ArrayList<>(); + List 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 books) { + Set authorNames = new HashSet<>(); + Set 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 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(); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaAlternateTitleDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaAlternateTitleDto.java new file mode 100644 index 000000000..d25e1ced4 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaAlternateTitleDto.java @@ -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; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaAuthorDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaAuthorDto.java new file mode 100644 index 000000000..71562eab9 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaAuthorDto.java @@ -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; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaBookDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaBookDto.java new file mode 100644 index 000000000..87fc94191 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaBookDto.java @@ -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; +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaBookMetadataAggregationDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaBookMetadataAggregationDto.java new file mode 100644 index 000000000..cda91b76d --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaBookMetadataAggregationDto.java @@ -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 authors = new ArrayList<>(); + + @Builder.Default + private List tags = new ArrayList<>(); + + private String releaseDate; + + private String summary; + private String summaryNumber; + + private Boolean summaryLock; + + private Instant created; + private Instant lastModified; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaBookMetadataDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaBookMetadataDto.java new file mode 100644 index 000000000..9347e5581 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaBookMetadataDto.java @@ -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 authors = new ArrayList<>(); + private Boolean authorsLock; + + @Builder.Default + private List tags = new ArrayList<>(); + private Boolean tagsLock; + + private String isbn; + private Boolean isbnLock; + + @Builder.Default + private List links = new ArrayList<>(); + private Boolean linksLock; + + private Instant created; + private Instant lastModified; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaCollectionDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaCollectionDto.java new file mode 100644 index 000000000..461b7839e --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaCollectionDto.java @@ -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; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaLibraryDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaLibraryDto.java new file mode 100644 index 000000000..b4038ca0f --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaLibraryDto.java @@ -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; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaMediaDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaMediaDto.java new file mode 100644 index 000000000..0aab01191 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaMediaDto.java @@ -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; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaPageDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaPageDto.java new file mode 100644 index 000000000..204749630 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaPageDto.java @@ -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; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaPageableDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaPageableDto.java new file mode 100644 index 000000000..a3357d7b8 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaPageableDto.java @@ -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 { + private List 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; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaReadProgressDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaReadProgressDto.java new file mode 100644 index 000000000..121e724bb --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaReadProgressDto.java @@ -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; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaSeriesDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaSeriesDto.java new file mode 100644 index 000000000..d2dcc5742 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaSeriesDto.java @@ -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; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaSeriesMetadataDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaSeriesMetadataDto.java new file mode 100644 index 000000000..d12f784af --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaSeriesMetadataDto.java @@ -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 genres = new ArrayList<>(); + private Boolean genresLock; + + @Builder.Default + private List tags = new ArrayList<>(); + private Boolean tagsLock; + + private Integer totalBookCount; + private Boolean totalBookCountLock; + + @Builder.Default + private List alternateTitles = new ArrayList<>(); + private Boolean alternateTitlesLock; + + @Builder.Default + private List links = new ArrayList<>(); + private Boolean linksLock; + + @Builder.Default + private List sharingLabels = new ArrayList<>(); + private Boolean sharingLabelsLock; + + private Instant created; + private Instant lastModified; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaUserDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaUserDto.java new file mode 100644 index 000000000..88744a42d --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaUserDto.java @@ -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 roles = new ArrayList<>(); + + @Builder.Default + private Boolean sharedAllLibraries = true; + + @Builder.Default + private List sharedLibrariesIds = new ArrayList<>(); + + @Builder.Default + private List labelsAllow = new ArrayList<>(); + + @Builder.Default + private List labelsExclude = new ArrayList<>(); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaWebLinkDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaWebLinkDto.java new file mode 100644 index 000000000..60b688811 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/komga/KomgaWebLinkDto.java @@ -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; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java index d75c07615..f48560997 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java @@ -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)), diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java index c50f6ee6b..2105ab0da 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java @@ -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; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java index 1fc0bf0af..5a3b003eb 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java @@ -157,4 +157,154 @@ public interface BookRepository extends JpaRepository, 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 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 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 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 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 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 findBooksBySeriesNameUngroupedByLibraryId( + @Param("seriesName") String seriesName, + @Param("libraryId") Long libraryId); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java index 23517752e..4c501ce3c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java @@ -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"))); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/komga/KomgaService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/komga/KomgaService.java new file mode 100644 index 000000000..cf19d6bfc --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/komga/KomgaService.java @@ -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 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 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 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 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 content = new ArrayList<>(); + for (String seriesName : pageSeriesNames) { + try { + // Load only the books for this specific series + List 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 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.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 allSeriesBooks = bookRepository.findAllWithMetadataByLibraryId(libraryId); + + // Find the series name that matches this slug + List 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 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 allBooks = bookRepository.findAllWithMetadataByLibraryId(libraryId); + + // Filter and sort books for this series + List 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 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.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 getAllBooks(Long libraryId, int page, int size) { + List 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 content = books.subList(fromIndex, toIndex).stream() + .map(book -> komgaMapper.toKomgaBookDto(book)) + .collect(Collectors.toList()); + + return KomgaPageableDto.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 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 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> groupBooksBySeries(List books) { + Map> seriesMap = new HashMap<>(); + + for (BookEntity book : books) { + String seriesName = komgaMapper.getBookSeriesName(book); + seriesMap.computeIfAbsent(seriesName, k -> new ArrayList<>()).add(book); + } + + return seriesMap; + } + + public KomgaPageableDto getCollections(int page, int size, boolean unpaged) { + log.debug("Getting collections, page: {}, size: {}, unpaged: {}", page, size, unpaged); + + List 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 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 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.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(); + } + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsUserV2Service.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsUserV2Service.java index 063ad555e..3a8b90e2f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsUserV2Service.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsUserV2Service.java @@ -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); + } } \ No newline at end of file diff --git a/booklore-api/src/main/resources/db/migration/V98__Add_komga_settings.sql b/booklore-api/src/main/resources/db/migration/V98__Add_komga_settings.sql new file mode 100644 index 000000000..23927503d --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V98__Add_komga_settings.sql @@ -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' +); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/config/KomgaCleanFilterTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/config/KomgaCleanFilterTest.java new file mode 100644 index 000000000..80bdb680c --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/config/KomgaCleanFilterTest.java @@ -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 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 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 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 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 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 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 + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/config/KomgaCleanModeDemo.java b/booklore-api/src/test/java/com/adityachandel/booklore/config/KomgaCleanModeDemo.java new file mode 100644 index 000000000..839b738ab --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/config/KomgaCleanModeDemo.java @@ -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"); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/interceptor/KomgaCleanInterceptorTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/interceptor/KomgaCleanInterceptorTest.java new file mode 100644 index 000000000..7b09231e5 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/interceptor/KomgaCleanInterceptorTest.java @@ -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(); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/mapper/komga/KomgaMapperTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/mapper/komga/KomgaMapperTest.java new file mode 100644 index 000000000..95712e2ca --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/mapper/komga/KomgaMapperTest.java @@ -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 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 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(""); + } +} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/komga/KomgaServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/komga/KomgaServiceTest.java new file mode 100644 index 000000000..cbe24facf --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/komga/KomgaServiceTest.java @@ -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 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 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 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 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 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 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 seriesABooks = List.of(seriesBooks.get(0), seriesBooks.get(1)); + List 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 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(); + } +} \ No newline at end of file diff --git a/booklore-ui/src/app/features/settings/opds-settings/opds-settings.html b/booklore-ui/src/app/features/settings/opds-settings/opds-settings.html index 870ccf0e0..fdd35ec65 100644 --- a/booklore-ui/src/app/features/settings/opds-settings/opds-settings.html +++ b/booklore-ui/src/app/features/settings/opds-settings/opds-settings.html @@ -40,6 +40,93 @@ +
+
+

+ + Komga API +

+
+ +
+
+
+
+ + + +
+

+ + Enable the Komga-compatible API to use Komga clients (Tachiyomi, Komelia, etc.) with Booklore. Uses the same OPDS user accounts for authentication. +

+
+
+ + @if (komgaApiEnabled) { +
+
+
+ + + +
+

+ + 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. +

+
+
+ } +
+
+ + @if (komgaApiEnabled) { +
+
+

+ + Komga API Endpoint +

+
+ +
+
+
+
+ +
+ + + +
+
+

+ + Use this URL as the server address in Komga-compatible clients. The API is available at /komga/api/v1/* and uses OPDS user credentials. +

+
+
+
+
+ } + @if (opdsEnabled) {
@@ -310,4 +397,4 @@
} - + \ No newline at end of file diff --git a/booklore-ui/src/app/features/settings/opds-settings/opds-settings.ts b/booklore-ui/src/app/features/settings/opds-settings/opds-settings.ts index 0bd2e3d3e..fa51942d8 100644 --- a/booklore-ui/src/app/features/settings/opds-settings/opds-settings.ts +++ b/booklore-ui/src/app/features/settings/opds-settings/opds-settings.ts @@ -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(); } -} +} \ No newline at end of file diff --git a/booklore-ui/src/app/shared/model/app-settings.model.ts b/booklore-ui/src/app/shared/model/app-settings.model.ts index 57e258b97..af445a8bc 100644 --- a/booklore-ui/src/app/shared/model/app-settings.model.ts +++ b/booklore-ui/src/app/shared/model/app-settings.model.ts @@ -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', diff --git a/booklore-ui/src/app/shared/service/app-settings.service.spec.ts b/booklore-ui/src/app/shared/service/app-settings.service.spec.ts index 1d40c43bc..d9f1cbac1 100644 --- a/booklore-ui/src/app/shared/service/app-settings.service.spec.ts +++ b/booklore-ui/src/app/shared/service/app-settings.service.spec.ts @@ -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: { diff --git a/docs/Komga-API.md b/docs/Komga-API.md new file mode 100644 index 000000000..a6d5870aa --- /dev/null +++ b/docs/Komga-API.md @@ -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 diff --git a/docs/komga-clean-mode.md b/docs/komga-clean-mode.md new file mode 100644 index 000000000..b0147f453 --- /dev/null +++ b/docs/komga-clean-mode.md @@ -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 diff --git a/nginx.conf b/nginx.conf index d8d77ea20..999585e6a 100644 --- a/nginx.conf +++ b/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