feat: enhance API response structure and error handling

This commit is contained in:
Rodrigo Verdiani 2025-10-25 10:37:11 -03:00
parent bb8b293573
commit 1526dc4bc9
23 changed files with 631 additions and 342 deletions

View File

@ -2,12 +2,11 @@ package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.AuthenticationRequestDTO;
import com.magamochi.mangamochi.model.dto.AuthenticationResponseDTO;
import com.magamochi.mangamochi.model.dto.DefaultResponseDTO;
import com.magamochi.mangamochi.model.dto.RegistrationRequestDTO;
import com.magamochi.mangamochi.model.repository.UserRepository;
import com.magamochi.mangamochi.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
@RestController
@ -16,8 +15,6 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class AuthenticationController {
private final UserService userService;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Operation(
summary = "Authenticate user",
@ -25,9 +22,9 @@ public class AuthenticationController {
tags = {"Auth"},
operationId = "authenticateUser")
@PostMapping("/login")
public AuthenticationResponseDTO authenticateUser(
public DefaultResponseDTO<AuthenticationResponseDTO> authenticateUser(
@RequestBody AuthenticationRequestDTO authenticationRequestDTO) {
return userService.authenticate(authenticationRequestDTO);
return DefaultResponseDTO.ok(userService.authenticate(authenticationRequestDTO));
}
@Operation(
@ -36,7 +33,10 @@ public class AuthenticationController {
tags = {"Auth"},
operationId = "registerUser")
@PostMapping("/register")
public void registerUser(@RequestBody RegistrationRequestDTO registrationRequestDTO) {
public DefaultResponseDTO<Void> registerUser(
@RequestBody RegistrationRequestDTO registrationRequestDTO) {
userService.register(registrationRequestDTO);
return DefaultResponseDTO.ok().build();
}
}

View File

@ -1,5 +1,6 @@
package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.DefaultResponseDTO;
import com.magamochi.mangamochi.model.dto.GenreDTO;
import com.magamochi.mangamochi.service.GenreService;
import io.swagger.v3.oas.annotations.Operation;
@ -19,7 +20,7 @@ public class GenreController {
tags = {"Genre"},
operationId = "getGenres")
@GetMapping
public List<GenreDTO> getGenres() {
return genreService.getGenres();
public DefaultResponseDTO<List<GenreDTO>> getGenres() {
return DefaultResponseDTO.ok(genreService.getGenres());
}
}

View File

@ -0,0 +1,83 @@
package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.*;
import com.magamochi.mangamochi.model.enumeration.ArchiveFileType;
import com.magamochi.mangamochi.service.MangaChapterService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/mangas/chapters")
@RequiredArgsConstructor
public class MangaChapterController {
private final MangaChapterService mangaChapterService;
@Operation(
summary = "Fetch chapter",
description = "Fetch the chapter from the provider",
tags = {"Manga Chapter"},
operationId = "fetchChapter")
@PostMapping(value = "/{chapterId}/fetch")
public DefaultResponseDTO<Void> fetchChapter(@PathVariable Long chapterId) {
mangaChapterService.fetchChapter(chapterId);
return DefaultResponseDTO.ok().build();
}
@Operation(
summary = "Get the images for a specific manga/provider combination",
description =
"Retrieve a list of manga chapter images for a specific manga/provider combination.",
tags = {"Manga Chapter"},
operationId = "getMangaChapterImages")
@GetMapping("/{chapterId}/images")
public DefaultResponseDTO<MangaChapterImagesDTO> getMangaChapterImages(
@PathVariable Long chapterId) {
return DefaultResponseDTO.ok(mangaChapterService.getMangaChapterImages(chapterId));
}
@Operation(
summary = "Mark a chapter as read",
description = "Mark a chapter as read by its ID.",
tags = {"Manga Chapter"},
operationId = "markAsRead")
@PostMapping("/{chapterId}/mark-as-read")
public DefaultResponseDTO<Void> markAsRead(@PathVariable Long chapterId) {
mangaChapterService.markAsRead(chapterId);
return DefaultResponseDTO.ok().build();
}
@Operation(
summary = "Download chapter archive",
description = "Download a chapter as a compressed file by its ID.",
tags = {"Manga Chapter"},
operationId = "downloadChapterArchive")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "Successful download",
content =
@Content(
mediaType = "application/octet-stream",
schema = @Schema(type = "string", format = "binary"))),
})
@PostMapping(value = "/{chapterId}/download", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<byte[]> downloadChapterArchive(
@PathVariable Long chapterId, @RequestParam ArchiveFileType archiveFileType)
throws IOException {
var response = mangaChapterService.downloadChapter(chapterId, archiveFileType);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + response.filename() + "\"")
.body(response.content());
}
}

View File

@ -1,23 +1,15 @@
package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.*;
import com.magamochi.mangamochi.model.enumeration.ArchiveFileType;
import com.magamochi.mangamochi.service.MangaService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import java.io.IOException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@ -32,19 +24,20 @@ public class MangaController {
tags = {"Manga"},
operationId = "getMangas")
@GetMapping
public Page<MangaListDTO> getMangas(
public DefaultResponseDTO<Page<MangaListDTO>> getMangas(
@ParameterObject MangaListFilterDTO filterDTO,
@Parameter(hidden = true) @ParameterObject @PageableDefault Pageable pageable) {
return mangaService.getMangas(filterDTO, pageable);
return DefaultResponseDTO.ok(mangaService.getMangas(filterDTO, pageable));
}
@Operation(
summary = "Get the details of a manga",
description = "Get the details of a manga by its ID",
tags = {"Manga"},
operationId = "getManga")
@GetMapping("/{mangaId}")
public MangaDTO getManga(@PathVariable Long mangaId) {
return mangaService.getManga(mangaId);
public DefaultResponseDTO<MangaDTO> getManga(@PathVariable Long mangaId) {
return DefaultResponseDTO.ok(mangaService.getManga(mangaId));
}
@Operation(
@ -53,8 +46,9 @@ public class MangaController {
tags = {"Manga"},
operationId = "getMangaChapters")
@GetMapping("/{mangaProviderId}/chapters")
public List<MangaChapterDTO> getMangaChapters(@PathVariable Long mangaProviderId) {
return mangaService.getMangaChapters(mangaProviderId);
public DefaultResponseDTO<List<MangaChapterDTO>> getMangaChapters(
@PathVariable Long mangaProviderId) {
return DefaultResponseDTO.ok(mangaService.getMangaChapters(mangaProviderId));
}
@Operation(
@ -63,74 +57,9 @@ public class MangaController {
tags = {"Manga"},
operationId = "fetchMangaChapters")
@PostMapping("/{mangaProviderId}/fetch-chapters")
public void fetchMangaChapters(@PathVariable Long mangaProviderId) {
public DefaultResponseDTO<Void> fetchMangaChapters(@PathVariable Long mangaProviderId) {
mangaService.fetchMangaChapters(mangaProviderId);
}
@Operation(summary = "Fetch chapter", operationId = "fetchChapter")
@PostMapping(value = "/chapter/{chapterId}/fetch")
public ResponseEntity<Void> fetchChapter(@PathVariable Long chapterId) {
mangaService.fetchChapter(chapterId);
return ResponseEntity.ok().build();
}
@Operation(
summary = "Get the available chapters for a specific manga/provider combination",
description = "Retrieve a list of manga chapters for a specific manga/provider combination.",
tags = {"Manga"},
operationId = "getMangaChapterImages")
@GetMapping("/{chapterId}/images")
public MangaChapterImagesDTO getMangaChapterImages(@PathVariable Long chapterId) {
return mangaService.getMangaChapterImages(chapterId);
}
@Operation(
summary = "Mark a chapter as read",
description = "Mark a chapter as read by its ID.",
tags = {"Manga"},
operationId = "markAsRead")
@PostMapping("/{chapterId}/mark-as-read")
public void markAsRead(@PathVariable Long chapterId) {
mangaService.markAsRead(chapterId);
}
@Operation(
summary = "Download all chapters for a manga provider",
operationId = "downloadAllChapters")
@PostMapping(value = "/chapter/{mangaProviderId}/download-all")
public ResponseEntity<Void> downloadAllChapters(@PathVariable Long mangaProviderId) {
mangaService.downloadAllChapters(mangaProviderId);
return ResponseEntity.ok().build();
}
@Operation(summary = "Download chapter archive", operationId = "downloadChapterArchive")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "Successful download",
content =
@Content(
mediaType = "application/octet-stream",
schema = @Schema(type = "string", format = "binary"))),
})
@PostMapping(
value = "/chapter/{chapterId}/download-archive",
produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<byte[]> downloadChapterArchive(
@PathVariable Long chapterId, @RequestParam ArchiveFileType archiveFileType)
throws IOException {
var response = mangaService.downloadChapter(chapterId, archiveFileType);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + response.filename() + "\"")
.body(response.content());
}
@Operation(summary = "Update manga info", operationId = "updateMangaInfo")
@PostMapping(value = "/manga/{mangaId}/info")
public ResponseEntity<Void> updateMangaInfo(@PathVariable Long mangaId) {
mangaService.updateInfo(mangaId);
return ResponseEntity.ok().build();
return DefaultResponseDTO.ok().build();
}
}

View File

@ -27,9 +27,9 @@ public class MangaImportController {
tags = {"Manga Import"},
operationId = "importFromMangaDex")
@PostMapping("/manga-dex")
public ImportMangaDexResponseDTO importFromMangaDex(
public DefaultResponseDTO<ImportMangaDexResponseDTO> importFromMangaDex(
@RequestBody ImportMangaDexRequestDTO requestDTO) {
return mangaDexProvider.importManga(requestDTO.id());
return DefaultResponseDTO.ok(mangaDexProvider.importManga(requestDTO.id()));
}
@Operation(
@ -40,7 +40,7 @@ public class MangaImportController {
@PostMapping(
value = "/upload",
consumes = {"multipart/form-data"})
public void uploadMultipleFiles(
public DefaultResponseDTO<Void> uploadMultipleFiles(
@RequestPart("malId") @NotBlank String malId,
@Parameter(
description = "List of files to upload",
@ -53,5 +53,7 @@ public class MangaImportController {
@NotNull
List<MultipartFile> files) {
mangaImportService.importMangaFiles(malId, files);
return DefaultResponseDTO.ok().build();
}
}

View File

@ -0,0 +1,51 @@
package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.DefaultResponseDTO;
import com.magamochi.mangamochi.model.dto.ImportReviewDTO;
import com.magamochi.mangamochi.service.MangaImportReviewService;
import io.swagger.v3.oas.annotations.Operation;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/manga/import/review")
@RequiredArgsConstructor
public class MangaImportReviewController {
private final MangaImportReviewService mangaImportReviewService;
@Operation(
summary = "Get list of pending import reviews",
description = "Get list of pending import reviews.",
tags = {"Manga Import Review"},
operationId = "getImportReviews")
@GetMapping
public DefaultResponseDTO<List<ImportReviewDTO>> getImportReviews() {
return DefaultResponseDTO.ok(mangaImportReviewService.getImportReviews());
}
@Operation(
summary = "Delete pending import review",
description = "Delete pending import review by ID.",
tags = {"Manga Import Review"},
operationId = "deleteImportReview")
@DeleteMapping("/{id}")
public DefaultResponseDTO<Void> deleteImportReview(@PathVariable Long id) {
mangaImportReviewService.deleteImportReview(id);
return DefaultResponseDTO.ok().build();
}
@Operation(
summary = "Resolve import review",
description = "Resolve import review by ID.",
tags = {"Manga Import Review"},
operationId = "resolveImportReview")
@PostMapping
public DefaultResponseDTO<Void> resolveImportReview(
@RequestParam Long importReviewId, @RequestParam String malId) {
mangaImportReviewService.resolveImportReview(importReviewId, malId);
return DefaultResponseDTO.ok().build();
}
}

View File

@ -1,5 +1,6 @@
package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.DefaultResponseDTO;
import com.magamochi.mangamochi.service.UserFavoriteMangaService;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
@ -17,8 +18,10 @@ public class UserFavoriteMangaController {
tags = {"Favorite Mangas"},
operationId = "setFavorite")
@PostMapping("/{id}/favorite")
public void setFavorite(@PathVariable Long id) {
public DefaultResponseDTO<Void> setFavorite(@PathVariable Long id) {
userFavoriteMangaService.setFavorite(id);
return DefaultResponseDTO.ok().build();
}
@Operation(
@ -27,7 +30,9 @@ public class UserFavoriteMangaController {
tags = {"Favorite Mangas"},
operationId = "setUnfavorite")
@PostMapping("/{id}/unfavorite")
public void setUnfavorite(@PathVariable Long id) {
public DefaultResponseDTO<Void> setUnfavorite(@PathVariable Long id) {
userFavoriteMangaService.setUnfavorite(id);
return DefaultResponseDTO.ok().build();
}
}

View File

@ -0,0 +1,7 @@
package com.magamochi.mangamochi.exception;
public class ConflictException extends RuntimeException {
public ConflictException(String message) {
super(message);
}
}

View File

@ -0,0 +1,65 @@
package com.magamochi.mangamochi.exception;
import com.magamochi.mangamochi.model.dto.ErrorResponseDTO;
import java.time.Instant;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponseDTO> handleRuntimeException() {
var error =
new ErrorResponseDTO(
Instant.now(), "An unexpected error occurred. Please try again later.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponseDTO> handleAuthenticationException() {
var error = new ErrorResponseDTO(Instant.now(), "An error occurred during authentication.");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ErrorResponseDTO> handleBadCredentialsException(BadCredentialsException e) {
var error = new ErrorResponseDTO(Instant.now(), e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
@ExceptionHandler(UsernameNotFoundException.class)
public ResponseEntity<ErrorResponseDTO> handleUsernameNotFoundException() {
var error = new ErrorResponseDTO(Instant.now(), "Username not found.");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponseDTO> handleNotFoundException(NotFoundException e) {
var error = new ErrorResponseDTO(Instant.now(), e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(ConflictException.class)
public ResponseEntity<ErrorResponseDTO> handleConflictException(ConflictException e) {
var error = new ErrorResponseDTO(Instant.now(), e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
@ExceptionHandler(UnprocessableException.class)
public ResponseEntity<ErrorResponseDTO> handleUnprocessableException(UnprocessableException e) {
var error = new ErrorResponseDTO(Instant.now(), e.getMessage());
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(error);
}
}

View File

@ -0,0 +1,7 @@
package com.magamochi.mangamochi.exception;
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}

View File

@ -0,0 +1,7 @@
package com.magamochi.mangamochi.exception;
public class UnprocessableException extends RuntimeException {
public UnprocessableException(String message) {
super(message);
}
}

View File

@ -0,0 +1,29 @@
package com.magamochi.mangamochi.model.dto;
import jakarta.annotation.Nullable;
import java.time.Instant;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class DefaultResponseDTO<T> {
private Instant timestamp;
private T data;
private String message;
private static <T> DefaultResponseDTOBuilder<T> init() {
return DefaultResponseDTO.<T>builder().timestamp(Instant.now());
}
public static DefaultResponseDTOBuilder<Void> ok() {
return DefaultResponseDTO.init();
}
public static <T> DefaultResponseDTO<T> ok(@Nullable T data) {
DefaultResponseDTOBuilder<T> defaultResponseDTOBuilder = DefaultResponseDTO.init();
defaultResponseDTOBuilder.data(data);
return defaultResponseDTOBuilder.build();
}
}

View File

@ -0,0 +1,7 @@
package com.magamochi.mangamochi.model.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
public record ErrorResponseDTO(@NotNull Instant timestamp, @NotBlank String message) {}

View File

@ -0,0 +1,24 @@
package com.magamochi.mangamochi.model.dto;
import com.magamochi.mangamochi.model.entity.MangaImportReview;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
public record ImportReviewDTO(
@NotNull Long id,
@NotBlank String title,
@NotBlank String providerName,
String externalUrl,
@NotBlank String reason,
@NotNull Instant createdAt) {
public static ImportReviewDTO from(MangaImportReview review) {
return new ImportReviewDTO(
review.getId(),
review.getTitle(),
review.getProvider().getName(),
review.getUrl(),
"Title match not found",
review.getCreatedAt());
}
}

View File

@ -0,0 +1,154 @@
package com.magamochi.mangamochi.service;
import com.magamochi.mangamochi.exception.UnprocessableException;
import com.magamochi.mangamochi.model.dto.MangaChapterArchiveDTO;
import com.magamochi.mangamochi.model.dto.MangaChapterImagesDTO;
import com.magamochi.mangamochi.model.entity.MangaChapter;
import com.magamochi.mangamochi.model.entity.MangaChapterImage;
import com.magamochi.mangamochi.model.enumeration.ArchiveFileType;
import com.magamochi.mangamochi.model.repository.MangaChapterImageRepository;
import com.magamochi.mangamochi.model.repository.MangaChapterRepository;
import com.magamochi.mangamochi.service.providers.ContentProviderFactory;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Log4j2
@Service
@RequiredArgsConstructor
public class MangaChapterService {
private final MangaChapterRepository mangaChapterRepository;
private final MangaChapterImageRepository mangaChapterImageRepository;
private final ImageService imageService;
private final ContentProviderFactory contentProviderFactory;
@Transactional
public void fetchChapter(Long chapterId) {
var chapter = getMangaChapterThrowIfNotFound(chapterId);
var mangaProvider = chapter.getMangaProvider();
var provider = contentProviderFactory.getContentProvider(mangaProvider.getProvider().getName());
var chapterImagesUrls = provider.getChapterImagesUrls(chapter.getUrl());
if (chapterImagesUrls.isEmpty()) {
throw new UnprocessableException(
"No images found on provider for Manga Chapter ID: " + chapterId);
}
var chapterImages =
chapterImagesUrls.entrySet().stream()
.map(
entry -> {
try {
var inputStream =
new BufferedInputStream(
new URL(new URI(entry.getValue()).toASCIIString()).openStream());
var bytes = inputStream.readAllBytes();
inputStream.close();
var image =
imageService.uploadImage(bytes, "image/jpeg", "chapter/" + chapterId);
log.info(
"Downloaded image {}/{} for manga {} chapter {}: {}",
entry.getKey() + 1,
chapterImagesUrls.size(),
chapter.getMangaProvider().getManga().getTitle(),
chapterId,
entry.getValue());
return MangaChapterImage.builder()
.mangaChapter(chapter)
.position(entry.getKey())
.image(image)
.build();
} catch (IOException | URISyntaxException e) {
throw new UnprocessableException(
"Could not download image for chapter ID: " + chapterId);
}
})
.toList();
mangaChapterImageRepository.saveAll(chapterImages);
chapter.setDownloaded(true);
mangaChapterRepository.save(chapter);
}
public MangaChapterImagesDTO getMangaChapterImages(Long chapterId) {
var chapter = getMangaChapterThrowIfNotFound(chapterId);
return MangaChapterImagesDTO.from(chapter);
}
public void markAsRead(Long chapterId) {
var chapter = getMangaChapterThrowIfNotFound(chapterId);
chapter.setRead(true);
mangaChapterRepository.save(chapter);
}
public MangaChapterArchiveDTO downloadChapter(Long chapterId, ArchiveFileType archiveFileType)
throws IOException {
var chapter = getMangaChapterThrowIfNotFound(chapterId);
var chapterImages = mangaChapterImageRepository.findAllByMangaChapter(chapter);
var byteArrayOutputStream =
switch (archiveFileType) {
case CBZ -> getChapterCbzArchive(chapterImages);
default ->
throw new UnprocessableException(
"Unsupported archive file type: " + archiveFileType.name());
};
return new MangaChapterArchiveDTO(
chapter.getTitle() + ".cbz", byteArrayOutputStream.toByteArray());
}
private ByteArrayOutputStream getChapterCbzArchive(List<MangaChapterImage> chapterImages)
throws IOException {
var byteArrayOutputStream = new ByteArrayOutputStream();
var bufferedOutputStream = new BufferedOutputStream(byteArrayOutputStream);
var zipOutputStream = new ZipOutputStream(bufferedOutputStream);
var totalPages = chapterImages.size();
var paddingLength = String.valueOf(totalPages).length();
for (var pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
var imgSrc = chapterImages.get(pageNumber - 1);
var paddedFileName = String.format("%0" + paddingLength + "d.jpg", imgSrc.getPosition());
zipOutputStream.putNextEntry(new ZipEntry(paddedFileName));
IOUtils.copy(imageService.getImageStream(imgSrc.getImage()), zipOutputStream);
zipOutputStream.closeEntry();
}
zipOutputStream.finish();
zipOutputStream.flush();
IOUtils.closeQuietly(zipOutputStream);
return byteArrayOutputStream;
}
private MangaChapter getMangaChapterThrowIfNotFound(Long chapterId) {
return mangaChapterRepository
.findById(chapterId)
.orElseThrow(() -> new RuntimeException("Manga Chapter not found for ID: " + chapterId));
}
}

View File

@ -0,0 +1,77 @@
package com.magamochi.mangamochi.service;
import static java.util.Objects.isNull;
import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.mangamochi.client.JikanClient;
import com.magamochi.mangamochi.exception.NotFoundException;
import com.magamochi.mangamochi.model.dto.ImportReviewDTO;
import com.magamochi.mangamochi.model.entity.Manga;
import com.magamochi.mangamochi.model.entity.MangaImportReview;
import com.magamochi.mangamochi.model.entity.MangaProvider;
import com.magamochi.mangamochi.model.repository.MangaImportReviewRepository;
import com.magamochi.mangamochi.model.repository.MangaProviderRepository;
import com.magamochi.mangamochi.model.repository.MangaRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class MangaImportReviewService {
private final MangaImportReviewRepository mangaImportReviewRepository;
private final MangaRepository mangaRepository;
private final MangaProviderRepository mangaProviderRepository;
private final JikanClient jikanClient;
RateLimiter rateLimiter = RateLimiter.create(1);
public List<ImportReviewDTO> getImportReviews() {
return mangaImportReviewRepository.findAll().stream().map(ImportReviewDTO::from).toList();
}
public void deleteImportReview(Long id) {
var importReview = getImportReviewThrowIfNotFound(id);
mangaImportReviewRepository.delete(importReview);
}
public void resolveImportReview(Long id, String malId) {
var importReview = getImportReviewThrowIfNotFound(id);
rateLimiter.acquire();
var jikanResult = jikanClient.getMangaById(Long.parseLong(malId)).data();
if (isNull(jikanResult)) {
throw new NotFoundException("MyAnimeList manga not found for ID: " + id);
}
var manga =
mangaRepository
.findByTitleIgnoreCase(jikanResult.title())
.orElseGet(
() ->
mangaRepository.save(
Manga.builder()
.title(jikanResult.title())
.malId(Long.parseLong(malId))
.build()));
mangaProviderRepository.save(
MangaProvider.builder()
.manga(manga)
.mangaTitle(importReview.getTitle())
.provider(importReview.getProvider())
.url(importReview.getUrl())
.build());
mangaImportReviewRepository.delete(importReview);
}
private MangaImportReview getImportReviewThrowIfNotFound(Long id) {
return mangaImportReviewRepository
.findById(id)
.orElseThrow(() -> new NotFoundException("Import review not found for ID: " + id));
}
}

View File

@ -46,6 +46,7 @@ public class MangaImportService {
RateLimiter rateLimiter = RateLimiter.create(1);
public void importMangaFiles(String malId, List<MultipartFile> files) {
log.info("Importing manga files for MAL ID {}", malId);
var provider = providerService.getOrCreateProvider("Manual Import");
rateLimiter.acquire();
@ -114,7 +115,7 @@ public class MangaImportService {
mangaChapterRepository.save(chapter);
});
log.warn("test");
log.info("Import manga files for MAL ID {} completed.", malId);
}
public void updateMangaData(Manga manga) {
@ -135,30 +136,28 @@ public class MangaImportService {
var authors =
mangaData.data().authors().stream()
.map(
authorData -> {
return authorRepository
.findByMalId(authorData.mal_id())
.orElseGet(
() ->
authorRepository.save(
Author.builder()
.malId(authorData.mal_id())
.name(authorData.name())
.build()));
})
authorData ->
authorRepository
.findByMalId(authorData.mal_id())
.orElseGet(
() ->
authorRepository.save(
Author.builder()
.malId(authorData.mal_id())
.name(authorData.name())
.build())))
.toList();
var mangaAuthors =
authors.stream()
.map(
author -> {
return mangaAuthorRepository
.findByMangaAndAuthor(manga, author)
.orElseGet(
() ->
mangaAuthorRepository.save(
MangaAuthor.builder().manga(manga).author(author).build()));
})
author ->
mangaAuthorRepository
.findByMangaAndAuthor(manga, author)
.orElseGet(
() ->
mangaAuthorRepository.save(
MangaAuthor.builder().manga(manga).author(author).build())))
.toList();
manga.setMangaAuthors(mangaAuthors);
@ -166,30 +165,28 @@ public class MangaImportService {
var genres =
mangaData.data().genres().stream()
.map(
genreData -> {
return genreRepository
.findByMalId(genreData.mal_id())
.orElseGet(
() ->
genreRepository.save(
Genre.builder()
.malId(genreData.mal_id())
.name(genreData.name())
.build()));
})
genreData ->
genreRepository
.findByMalId(genreData.mal_id())
.orElseGet(
() ->
genreRepository.save(
Genre.builder()
.malId(genreData.mal_id())
.name(genreData.name())
.build())))
.toList();
var mangaGenres =
genres.stream()
.map(
genre -> {
return mangaGenreRepository
.findByMangaAndGenre(manga, genre)
.orElseGet(
() ->
mangaGenreRepository.save(
MangaGenre.builder().manga(manga).genre(genre).build()));
})
genre ->
mangaGenreRepository
.findByMangaAndGenre(manga, genre)
.orElseGet(
() ->
mangaGenreRepository.save(
MangaGenre.builder().manga(manga).genre(genre).build())))
.toList();
manga.setMangaGenres(mangaGenres);

View File

@ -2,30 +2,21 @@ package com.magamochi.mangamochi.service;
import static java.util.Objects.nonNull;
import com.magamochi.mangamochi.client.JikanClient;
import com.magamochi.mangamochi.exception.NotFoundException;
import com.magamochi.mangamochi.model.dto.*;
import com.magamochi.mangamochi.model.entity.Manga;
import com.magamochi.mangamochi.model.entity.MangaChapter;
import com.magamochi.mangamochi.model.entity.MangaChapterImage;
import com.magamochi.mangamochi.model.enumeration.ArchiveFileType;
import com.magamochi.mangamochi.model.entity.MangaProvider;
import com.magamochi.mangamochi.model.repository.*;
import com.magamochi.mangamochi.model.specification.MangaSpecification;
import com.magamochi.mangamochi.service.providers.ContentProviderFactory;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.*;
import java.net.URL;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@ -36,13 +27,8 @@ import org.springframework.stereotype.Service;
public class MangaService {
private final MangaImportService mangaImportService;
private final UserService userService;
private final MangaChapterRepository mangaChapterRepository;
private final MangaRepository mangaRepository;
private final MangaProviderRepository mangaProviderRepository;
private final MangaChapterImageRepository mangaChapterImageRepository;
private final ImageService imageService;
private final JikanClient jikanClient;
private final ContentProviderFactory contentProviderFactory;
private final UserFavoriteMangaRepository userFavoriteMangaRepository;
@ -69,148 +55,12 @@ public class MangaService {
}
public List<MangaChapterDTO> getMangaChapters(Long mangaProviderId) {
var mangaProvider =
mangaProviderRepository
.findById(mangaProviderId)
.orElseThrow(() -> new RuntimeException("manga provider not found"));
var mangaProvider = getMangaProviderThrowIfNotFound(mangaProviderId);
var chapters =
mangaProvider.getMangaChapters().stream()
.sorted(Comparator.comparing(MangaChapter::getId))
.map(MangaChapterDTO::from)
.toList();
return chapters;
}
public void fetchChapter(Long chapterId) {
try {
var chapter =
mangaChapterRepository
.findById(chapterId)
.orElseThrow(() -> new RuntimeException("Chapter not found"));
var mangaProvider = chapter.getMangaProvider();
var provider =
contentProviderFactory.getContentProvider(mangaProvider.getProvider().getName());
var chapterImagesUrls = provider.getChapterImagesUrls(chapter.getUrl());
if (chapterImagesUrls.isEmpty()) {
throw new RuntimeException("Chapter image not found");
}
var chapterImages =
chapterImagesUrls.entrySet().stream()
.map(
entry -> {
try {
var inputStream =
new BufferedInputStream(
new URL(new URI(entry.getValue()).toASCIIString()).openStream());
var bytes = inputStream.readAllBytes();
inputStream.close();
var image =
imageService.uploadImage(bytes, "image/jpeg", "chapter/" + chapterId);
log.info(
"Downloaded image {}/{} for manga {} chapter {}: {}",
entry.getKey() + 1,
chapterImagesUrls.size(),
chapter.getMangaProvider().getManga().getTitle(),
chapterId,
entry.getValue());
return MangaChapterImage.builder()
.mangaChapter(chapter)
.position(entry.getKey())
.image(image)
.build();
} catch (IOException | URISyntaxException e) {
throw new RuntimeException(e);
}
})
.toList();
mangaChapterImageRepository.saveAll(chapterImages);
chapter.setDownloaded(true);
mangaChapterRepository.save(chapter);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public MangaChapterArchiveDTO downloadChapter(Long chapterId, ArchiveFileType archiveFileType)
throws IOException {
var chapter =
mangaChapterRepository
.findById(chapterId)
.orElseThrow(() -> new RuntimeException("Chapter not found"));
var chapterImages = mangaChapterImageRepository.findAllByMangaChapter(chapter);
var byteArrayOutputStream =
switch (archiveFileType) {
case CBZ -> getChapterCbzArchive(chapterImages);
default -> throw new RuntimeException("Unsupported archive file type");
};
return new MangaChapterArchiveDTO(
chapter.getTitle() + ".cbz", byteArrayOutputStream.toByteArray());
}
private ByteArrayOutputStream getChapterCbzArchive(List<MangaChapterImage> chapterImages)
throws IOException {
var byteArrayOutputStream = new ByteArrayOutputStream();
var bufferedOutputStream = new BufferedOutputStream(byteArrayOutputStream);
var zipOutputStream = new ZipOutputStream(bufferedOutputStream);
var totalPages = chapterImages.size();
var paddingLength = String.valueOf(totalPages).length();
for (var pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
var imgSrc = chapterImages.get(pageNumber - 1);
var paddedFileName = String.format("%0" + paddingLength + "d.jpg", imgSrc.getPosition());
zipOutputStream.putNextEntry(new ZipEntry(paddedFileName));
IOUtils.copy(imageService.getImageStream(imgSrc.getImage()), zipOutputStream);
zipOutputStream.closeEntry();
}
zipOutputStream.finish();
zipOutputStream.flush();
IOUtils.closeQuietly(zipOutputStream);
return byteArrayOutputStream;
}
public void downloadAllChapters(Long mangaProviderId) {
var mangaProvider =
mangaProviderRepository
.findById(mangaProviderId)
.orElseThrow(() -> new RuntimeException("Manga provider not found"));
var mangaChapters = mangaChapterRepository.findByMangaProviderId(mangaProviderId);
mangaChapters.forEach(mangaChapter -> fetchChapter(mangaChapter.getId()));
}
public void updateInfo(Long mangaId) {
var manga = findMangaByIdThrowIfNotFound(mangaId);
var mangaSearchResponse = jikanClient.mangaSearch(manga.getTitle());
if (mangaSearchResponse.data().isEmpty()) {
throw new RuntimeException("Manga not found");
}
// TODO: create logic to select appropriate manga
var mangaResponse = mangaSearchResponse.data().getFirst();
manga.setTitle(mangaResponse.title());
manga.setMalId(mangaResponse.mal_id());
mangaRepository.save(manga);
return mangaProvider.getMangaChapters().stream()
.sorted(Comparator.comparing(MangaChapter::getId))
.map(MangaChapterDTO::from)
.toList();
}
public MangaDTO getManga(Long mangaId) {
@ -220,10 +70,7 @@ public class MangaService {
}
public void fetchMangaChapters(Long mangaProviderId) {
var mangaProvider =
mangaProviderRepository
.findById(mangaProviderId)
.orElseThrow(() -> new RuntimeException("manga provider not found"));
var mangaProvider = getMangaProviderThrowIfNotFound(mangaProviderId);
var contentProvider =
contentProviderFactory.getContentProvider(mangaProvider.getProvider().getName());
@ -233,28 +80,16 @@ public class MangaService {
chapter -> mangaImportService.persistMangaChapter(mangaProvider, chapter));
}
public MangaChapterImagesDTO getMangaChapterImages(Long chapterId) {
var chapter =
mangaChapterRepository
.findById(chapterId)
.orElseThrow(() -> new RuntimeException("Chapter not found"));
return MangaChapterImagesDTO.from(chapter);
}
public void markAsRead(Long chapterId) {
var chapter =
mangaChapterRepository
.findById(chapterId)
.orElseThrow(() -> new RuntimeException("Chapter not found"));
chapter.setRead(true);
mangaChapterRepository.save(chapter);
}
public Manga findMangaByIdThrowIfNotFound(Long mangaId) {
return mangaRepository
.findById(mangaId)
.orElseThrow(() -> new RuntimeException("Manga not found"));
.orElseThrow(() -> new NotFoundException("Manga not found for ID: " + mangaId));
}
private MangaProvider getMangaProviderThrowIfNotFound(Long mangaProviderId) {
return mangaProviderRepository
.findById(mangaProviderId)
.orElseThrow(
() -> new NotFoundException("Manga Provider not found for ID: " + mangaProviderId));
}
}

View File

@ -1,10 +1,10 @@
package com.magamochi.mangamochi.service;
import com.magamochi.mangamochi.exception.NotFoundException;
import com.magamochi.mangamochi.model.entity.Manga;
import com.magamochi.mangamochi.model.entity.UserFavoriteManga;
import com.magamochi.mangamochi.model.repository.MangaRepository;
import com.magamochi.mangamochi.model.repository.UserFavoriteMangaRepository;
import java.util.NoSuchElementException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@ -33,7 +33,10 @@ public class UserFavoriteMangaService {
var favoriteManga =
userFavoriteMangaRepository
.findByUserAndManga(user, manga)
.orElseThrow(() -> new NoSuchElementException("No manga found"));
.orElseThrow(
() ->
new NotFoundException(
"Error while trying to unfavorite manga. Please try again later."));
userFavoriteMangaRepository.delete(favoriteManga);
}
@ -41,6 +44,6 @@ public class UserFavoriteMangaService {
private Manga findMangaByIdThrowIfNotFound(Long mangaId) {
return mangaRepository
.findById(mangaId)
.orElseThrow(() -> new RuntimeException("Manga not found"));
.orElseThrow(() -> new NotFoundException("Manga not found for ID: " + mangaId));
}
}

View File

@ -2,6 +2,8 @@ package com.magamochi.mangamochi.service;
import static java.util.Objects.isNull;
import com.magamochi.mangamochi.exception.ConflictException;
import com.magamochi.mangamochi.exception.NotFoundException;
import com.magamochi.mangamochi.model.dto.AuthenticationRequestDTO;
import com.magamochi.mangamochi.model.dto.AuthenticationResponseDTO;
import com.magamochi.mangamochi.model.dto.RegistrationRequestDTO;
@ -10,13 +12,17 @@ import com.magamochi.mangamochi.model.enumeration.UserRole;
import com.magamochi.mangamochi.model.repository.UserRepository;
import com.magamochi.mangamochi.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class UserService {
@ -31,8 +37,12 @@ public class UserService {
try {
authenticationManager.authenticate(token);
} catch (Exception e) {
throw new RuntimeException("Authentication failed", e);
} catch (AuthenticationException e) {
if (e.getMessage().equals("Bad credentials")) {
throw new BadCredentialsException("Wrong email or password.");
}
throw e;
}
var userDetails = userDetailsService.loadUserByUsername(request.email());
@ -46,7 +56,7 @@ public class UserService {
public void register(RegistrationRequestDTO request) {
if (userRepository.existsByEmail(request.email())) {
throw new RuntimeException("Email is already taken");
throw new ConflictException("An user with this email already exists.");
}
userRepository.save(
@ -61,7 +71,7 @@ public class UserService {
public User getLoggedUserThrowIfNotFound() {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (isNull(authentication) || authentication.getName().equals("anonymousUser")) {
throw new RuntimeException("No authenticated user found");
throw new NotFoundException("User not found.");
}
return findUserByEmailThrowIfNotFound(authentication.getName());
@ -79,6 +89,6 @@ public class UserService {
private User findUserByEmailThrowIfNotFound(String email) {
return userRepository
.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found"));
.orElseThrow(() -> new NotFoundException("User not found."));
}
}

View File

@ -5,6 +5,8 @@ import static java.util.Objects.isNull;
import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.mangamochi.client.MangaDexAuthenticationClient;
import com.magamochi.mangamochi.client.MangaDexClient;
import com.magamochi.mangamochi.exception.NotFoundException;
import com.magamochi.mangamochi.exception.UnprocessableException;
import com.magamochi.mangamochi.model.dto.ContentProviderMangaChapterResponseDTO;
import com.magamochi.mangamochi.model.dto.ContentProviderMangaInfoResponseDTO;
import com.magamochi.mangamochi.model.dto.ImportMangaDexResponseDTO;
@ -118,17 +120,14 @@ public class MangaDexProvider implements ContentProvider {
.map(s -> chapter.baseUrl() + "/data/" + chapter.chapter().hash() + "/" + s)
.toList();
var map =
IntStream.range(0, chapterImageHashes.size())
.boxed()
.collect(
Collectors.toMap(
i -> i,
chapterImageHashes::get,
(existing, replacement) -> existing,
LinkedHashMap::new));
return map;
return IntStream.range(0, chapterImageHashes.size())
.boxed()
.collect(
Collectors.toMap(
i -> i,
chapterImageHashes::get,
(existing, replacement) -> existing,
LinkedHashMap::new));
}
public ImportMangaDexResponseDTO importManga(UUID id) {
@ -138,7 +137,7 @@ public class MangaDexProvider implements ContentProvider {
var resultData = mangaDexClient.getManga(id, token).data();
if (resultData.attributes().title().isEmpty()) {
throw new NoSuchElementException("Manga title not found for ID: " + id);
throw new UnprocessableException("Manga title not found for ID: " + id);
}
var mangaTitle =
@ -149,7 +148,7 @@ public class MangaDexProvider implements ContentProvider {
"en",
resultData.attributes().title().values().stream()
.findFirst()
.orElseThrow(() -> new NoSuchElementException("No title available")));
.orElseThrow(() -> new UnprocessableException("No title available")));
var provider =
providerRepository
@ -162,7 +161,7 @@ public class MangaDexProvider implements ContentProvider {
var manga = mangaCreationService.getOrCreateManga(mangaTitle, id.toString(), provider);
if (isNull(manga)) {
throw new NoSuchElementException("Manga not found for ID: " + id);
throw new NotFoundException("Manga could not be found or created for ID: " + id);
}
mangaProviderRepository.save(

View File

@ -2,7 +2,6 @@ package com.magamochi.mangamochi.task;
import static java.util.Objects.isNull;
import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.mangamochi.model.repository.*;
import com.magamochi.mangamochi.service.MangaImportService;
import lombok.RequiredArgsConstructor;
@ -19,8 +18,6 @@ public class UpdateMangaDataTask {
@Scheduled(fixedDelayString = "1d")
public void updateMangaData() {
var rateLimiter = RateLimiter.create(1);
var mangas =
mangaRepository.findAll().stream().filter(manga -> isNull(manga.getScore())).toList();

View File

@ -16,8 +16,8 @@ spring:
default-schema: mangamochi
servlet:
multipart:
max-file-size: 250MB
max-request-size: 2GB
max-file-size: 800MB
max-request-size: 4GB
springdoc:
api-docs: