feat: content download

This commit is contained in:
Rodrigo Verdiani 2026-03-19 11:31:27 -03:00
parent 5da02723cb
commit f3def583ae
10 changed files with 74 additions and 125 deletions

View File

@ -3,21 +3,28 @@ package com.magamochi.content.controller;
import com.magamochi.common.model.dto.DefaultResponseDTO; import com.magamochi.common.model.dto.DefaultResponseDTO;
import com.magamochi.content.model.dto.MangaContentDTO; import com.magamochi.content.model.dto.MangaContentDTO;
import com.magamochi.content.model.dto.MangaContentImagesDTO; import com.magamochi.content.model.dto.MangaContentImagesDTO;
import com.magamochi.content.model.enumeration.ContentArchiveFileType;
import com.magamochi.content.service.ContentDownloadService;
import com.magamochi.content.service.ContentService; import com.magamochi.content.service.ContentService;
import io.swagger.v3.oas.annotations.Operation; 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 jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.io.IOException;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequestMapping("/content") @RequestMapping("/content")
@RequiredArgsConstructor @RequiredArgsConstructor
public class ContentController { public class ContentController {
private final ContentService contentService; private final ContentService contentService;
private final ContentDownloadService contentDownloadService;
@Operation( @Operation(
summary = "Get the content for a specific manga/content provider combination", summary = "Get the content for a specific manga/content provider combination",
@ -41,4 +48,32 @@ public class ContentController {
@PathVariable Long mangaContentId) { @PathVariable Long mangaContentId) {
return DefaultResponseDTO.ok(contentService.getContentImages(mangaContentId)); return DefaultResponseDTO.ok(contentService.getContentImages(mangaContentId));
} }
@Operation(
summary = "Download content archive",
description = "Download content as a compressed file by its ID.",
tags = {"Content"},
operationId = "downloadContentArchive")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "Successful download",
content =
@Content(
mediaType = "application/octet-stream",
schema = @Schema(type = "string", format = "binary"))),
})
@PostMapping(
value = "/{mangaContentId}/download",
produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<byte[]> downloadContentArchive(
@PathVariable Long mangaContentId,
@RequestParam ContentArchiveFileType contentArchiveFileType)
throws IOException {
var response = contentDownloadService.downloadContent(mangaContentId, contentArchiveFileType);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + response.filename() + "\"")
.body(response.content());
}
} }

View File

@ -1,6 +1,6 @@
package com.magamochi.model.dto; package com.magamochi.content.model.dto;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
public record MangaChapterArchiveDTO(@NotBlank String filename, @NotNull byte[] content) {} public record MangaContentArchiveDTO(@NotBlank String filename, @NotNull byte[] content) {}

View File

@ -0,0 +1,6 @@
package com.magamochi.content.model.enumeration;
public enum ContentArchiveFileType {
CBZ,
CBR
}

View File

@ -1,12 +1,10 @@
package com.magamochi.service; package com.magamochi.content.service;
import com.magamochi.common.exception.UnprocessableException; import com.magamochi.common.exception.UnprocessableException;
import com.magamochi.content.model.entity.MangaContent; import com.magamochi.content.model.dto.MangaContentArchiveDTO;
import com.magamochi.content.model.entity.MangaContentImage; import com.magamochi.content.model.entity.MangaContentImage;
import com.magamochi.content.model.repository.MangaContentImageRepository; import com.magamochi.content.model.enumeration.ContentArchiveFileType;
import com.magamochi.content.model.repository.MangaContentRepository; import com.magamochi.image.service.ImageService;
import com.magamochi.model.dto.MangaChapterArchiveDTO;
import com.magamochi.model.enumeration.ArchiveFileType;
import java.io.BufferedOutputStream; import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -21,27 +19,24 @@ import org.springframework.stereotype.Service;
@Log4j2 @Log4j2
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class MangaChapterService { public class ContentDownloadService {
private final MangaContentRepository mangaContentRepository; private final ContentService contentService;
private final MangaContentImageRepository mangaContentImageRepository; private final ImageService imageService;
private final OldImageService oldImageService; public MangaContentArchiveDTO downloadContent(
Long mangaContentId, ContentArchiveFileType contentArchiveFileType) throws IOException {
public MangaChapterArchiveDTO downloadChapter(Long chapterId, ArchiveFileType archiveFileType) var chapter = contentService.find(mangaContentId);
throws IOException { var chapterImages = chapter.getMangaContentImages();
var chapter = getMangaChapterThrowIfNotFound(chapterId);
var chapterImages = mangaContentImageRepository.findAllByMangaContent(chapter);
var byteArrayOutputStream = var byteArrayOutputStream =
switch (archiveFileType) { switch (contentArchiveFileType) {
case CBZ -> getChapterCbzArchive(chapterImages); case CBZ -> getChapterCbzArchive(chapterImages);
default -> default ->
throw new UnprocessableException( throw new UnprocessableException(
"Unsupported archive file type: " + archiveFileType.name()); "Unsupported archive file type: " + contentArchiveFileType.name());
}; };
return new MangaChapterArchiveDTO( return new MangaContentArchiveDTO(
chapter.getTitle() + ".cbz", byteArrayOutputStream.toByteArray()); chapter.getTitle() + ".cbz", byteArrayOutputStream.toByteArray());
} }
@ -60,7 +55,7 @@ public class MangaChapterService {
var paddedFileName = String.format("%0" + paddingLength + "d.jpg", imgSrc.getPosition()); var paddedFileName = String.format("%0" + paddingLength + "d.jpg", imgSrc.getPosition());
zipOutputStream.putNextEntry(new ZipEntry(paddedFileName)); zipOutputStream.putNextEntry(new ZipEntry(paddedFileName));
IOUtils.copy(oldImageService.getImageStream(imgSrc.getImage()), zipOutputStream); IOUtils.copy(imageService.getStream(imgSrc.getImage()), zipOutputStream);
zipOutputStream.closeEntry(); zipOutputStream.closeEntry();
} }
@ -69,10 +64,4 @@ public class MangaChapterService {
IOUtils.closeQuietly(zipOutputStream); IOUtils.closeQuietly(zipOutputStream);
return byteArrayOutputStream; return byteArrayOutputStream;
} }
private MangaContent getMangaChapterThrowIfNotFound(Long chapterId) {
return mangaContentRepository
.findById(chapterId)
.orElseThrow(() -> new RuntimeException("Manga Chapter not found for ID: " + chapterId));
}
} }

View File

@ -1,46 +0,0 @@
package com.magamochi.controller;
import com.magamochi.model.enumeration.ArchiveFileType;
import com.magamochi.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 = "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

@ -3,6 +3,7 @@ package com.magamochi.image.service;
import com.magamochi.common.exception.NotFoundException; import com.magamochi.common.exception.NotFoundException;
import com.magamochi.image.model.entity.Image; import com.magamochi.image.model.entity.Image;
import com.magamochi.image.model.repository.ImageRepository; import com.magamochi.image.model.repository.ImageRepository;
import java.io.InputStream;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -51,4 +52,8 @@ public class ImageService {
public List<Image> findAll() { public List<Image> findAll() {
return imageRepository.findAll(); return imageRepository.findAll();
} }
public InputStream getStream(Image image) {
return s3Service.getFileStream(image.getObjectKey());
}
} }

View File

@ -2,6 +2,7 @@ package com.magamochi.image.service;
import static java.util.Objects.nonNull; import static java.util.Objects.nonNull;
import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@ -103,4 +104,10 @@ public class S3Service {
} }
} }
} }
public InputStream getFileStream(String key) {
var request = GetObjectRequest.builder().bucket(bucket).key(key).build();
return s3Client.getObject(request);
}
} }

View File

@ -1,6 +0,0 @@
package com.magamochi.model.enumeration;
public enum ArchiveFileType {
CBZ,
CBR
}

View File

@ -1,18 +0,0 @@
package com.magamochi.service;
import com.magamochi.image.model.entity.Image;
import java.io.InputStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class OldImageService {
private final OldS3Service oldS3Service;
public InputStream getImageStream(Image image) {
return oldS3Service.getFile(image.getObjectKey());
}
}

View File

@ -1,23 +0,0 @@
package com.magamochi.service;
import java.io.InputStream;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.*;
@Service
@RequiredArgsConstructor
public class OldS3Service {
@Value("${minio.bucket}")
private String bucket;
private final S3Client s3Client;
public InputStream getFile(String key) {
var request = GetObjectRequest.builder().bucket(bucket).key(key).build();
return s3Client.getObject(request);
}
}