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.content.model.dto.MangaContentDTO;
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 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 java.io.IOException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/content")
@RequiredArgsConstructor
public class ContentController {
private final ContentService contentService;
private final ContentDownloadService contentDownloadService;
@Operation(
summary = "Get the content for a specific manga/content provider combination",
@ -41,4 +48,32 @@ public class ContentController {
@PathVariable Long 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.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.content.model.entity.MangaContent;
import com.magamochi.content.model.dto.MangaContentArchiveDTO;
import com.magamochi.content.model.entity.MangaContentImage;
import com.magamochi.content.model.repository.MangaContentImageRepository;
import com.magamochi.content.model.repository.MangaContentRepository;
import com.magamochi.model.dto.MangaChapterArchiveDTO;
import com.magamochi.model.enumeration.ArchiveFileType;
import com.magamochi.content.model.enumeration.ContentArchiveFileType;
import com.magamochi.image.service.ImageService;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@ -21,27 +19,24 @@ import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class MangaChapterService {
private final MangaContentRepository mangaContentRepository;
private final MangaContentImageRepository mangaContentImageRepository;
public class ContentDownloadService {
private final ContentService contentService;
private final ImageService imageService;
private final OldImageService oldImageService;
public MangaChapterArchiveDTO downloadChapter(Long chapterId, ArchiveFileType archiveFileType)
throws IOException {
var chapter = getMangaChapterThrowIfNotFound(chapterId);
var chapterImages = mangaContentImageRepository.findAllByMangaContent(chapter);
public MangaContentArchiveDTO downloadContent(
Long mangaContentId, ContentArchiveFileType contentArchiveFileType) throws IOException {
var chapter = contentService.find(mangaContentId);
var chapterImages = chapter.getMangaContentImages();
var byteArrayOutputStream =
switch (archiveFileType) {
switch (contentArchiveFileType) {
case CBZ -> getChapterCbzArchive(chapterImages);
default ->
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());
}
@ -60,7 +55,7 @@ public class MangaChapterService {
var paddedFileName = String.format("%0" + paddingLength + "d.jpg", imgSrc.getPosition());
zipOutputStream.putNextEntry(new ZipEntry(paddedFileName));
IOUtils.copy(oldImageService.getImageStream(imgSrc.getImage()), zipOutputStream);
IOUtils.copy(imageService.getStream(imgSrc.getImage()), zipOutputStream);
zipOutputStream.closeEntry();
}
@ -69,10 +64,4 @@ public class MangaChapterService {
IOUtils.closeQuietly(zipOutputStream);
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.image.model.entity.Image;
import com.magamochi.image.model.repository.ImageRepository;
import java.io.InputStream;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
@ -51,4 +52,8 @@ public class ImageService {
public List<Image> 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 java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
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);
}
}