refactor-architecture #31

Merged
rov merged 5 commits from refactor-architecture into main 2026-03-19 16:17:58 -03:00
58 changed files with 720 additions and 835 deletions

View File

@ -3,7 +3,7 @@ package com.magamochi.catalog.model.entity;
import com.magamochi.catalog.model.enumeration.MangaState; import com.magamochi.catalog.model.enumeration.MangaState;
import com.magamochi.catalog.model.enumeration.MangaStatus; import com.magamochi.catalog.model.enumeration.MangaStatus;
import com.magamochi.image.model.entity.Image; import com.magamochi.image.model.entity.Image;
import com.magamochi.model.entity.UserFavoriteManga; import com.magamochi.userinteraction.model.entity.UserFavoriteManga;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.Instant; import java.time.Instant;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;

View File

@ -2,9 +2,13 @@ package com.magamochi.catalog.model.repository;
import com.magamochi.catalog.model.entity.MangaContentProvider; import com.magamochi.catalog.model.entity.MangaContentProvider;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface MangaContentProviderRepository extends JpaRepository<MangaContentProvider, Long> { public interface MangaContentProviderRepository extends JpaRepository<MangaContentProvider, Long> {
boolean existsByMangaTitleIgnoreCaseAndContentProvider_Id( boolean existsByMangaTitleIgnoreCaseAndContentProvider_Id(
@NotBlank String mangaTitle, long contentProviderId); @NotBlank String mangaTitle, long contentProviderId);
Optional<MangaContentProvider> findByManga_IdAndContentProvider_Id(
long mangaId, long contentProviderId);
} }

View File

@ -1,8 +1,10 @@
package com.magamochi.catalog.service; package com.magamochi.catalog.service;
import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.catalog.model.entity.MangaContentProvider; import com.magamochi.catalog.model.entity.MangaContentProvider;
import com.magamochi.catalog.model.repository.MangaContentProviderRepository; import com.magamochi.catalog.model.repository.MangaContentProviderRepository;
import com.magamochi.common.exception.NotFoundException; import com.magamochi.common.exception.NotFoundException;
import com.magamochi.ingestion.model.entity.ContentProvider;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -19,4 +21,17 @@ public class MangaContentProviderService {
new NotFoundException( new NotFoundException(
"MangaContentProvider not found - ID: " + mangaContentProviderId)); "MangaContentProvider not found - ID: " + mangaContentProviderId));
} }
public MangaContentProvider findOrCreate(Manga manga, ContentProvider contentProvider) {
return mangaContentProviderRepository
.findByManga_IdAndContentProvider_Id(manga.getId(), contentProvider.getId())
.orElseGet(
() ->
mangaContentProviderRepository.save(
MangaContentProvider.builder()
.manga(manga)
.mangaTitle(manga.getTitle())
.contentProvider(contentProvider)
.build()));
}
} }

View File

@ -98,6 +98,10 @@ public class MangaResolutionService {
return Optional.of(new ProviderResult(bestTitle, malId)); return Optional.of(new ProviderResult(bestTitle, malId));
} }
public Manga findOrCreateManga(Long aniListId, Long malId) {
return findOrCreateManga(null, aniListId, malId);
}
private Manga findOrCreateManga(String canonicalTitle, Long aniListId, Long malId) { private Manga findOrCreateManga(String canonicalTitle, Long aniListId, Long malId) {
if (nonNull(aniListId)) { if (nonNull(aniListId)) {
var existingByAniList = mangaRepository.findByAniListId(aniListId); var existingByAniList = mangaRepository.findByAniListId(aniListId);
@ -113,20 +117,24 @@ public class MangaResolutionService {
} }
} }
return mangaRepository if (nonNull(canonicalTitle)) {
.findByTitleIgnoreCase(canonicalTitle) var existingByTitle = mangaRepository.findByTitleIgnoreCase(canonicalTitle);
.orElseGet( if (existingByTitle.isPresent()) {
() -> { return existingByTitle.get();
var newManga = }
Manga.builder().title(canonicalTitle).malId(malId).aniListId(aniListId).build(); }
var savedManga = mangaRepository.save(newManga); return createAndNotifyManga(canonicalTitle, aniListId, malId);
}
mangaUpdateProducer.sendMangaUpdateCommand( private Manga createAndNotifyManga(String title, Long aniListId, Long malId) {
new MangaUpdateCommand(savedManga.getId())); var manga =
mangaRepository.save(
Manga.builder().title(title).aniListId(aniListId).malId(malId).build());
return savedManga; mangaUpdateProducer.sendMangaUpdateCommand(new MangaUpdateCommand(manga.getId()));
});
return manga;
} }
private record ProviderResult(String title, Long externalId) {} private record ProviderResult(String title, Long externalId) {}

View File

@ -8,10 +8,10 @@ import com.magamochi.catalog.model.dto.MangaListFilterDTO;
import com.magamochi.catalog.model.entity.Manga; import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.catalog.model.repository.MangaRepository; import com.magamochi.catalog.model.repository.MangaRepository;
import com.magamochi.common.exception.NotFoundException; import com.magamochi.common.exception.NotFoundException;
import com.magamochi.model.repository.UserFavoriteMangaRepository;
import com.magamochi.model.repository.UserMangaFollowRepository;
import com.magamochi.model.specification.MangaSpecification; import com.magamochi.model.specification.MangaSpecification;
import com.magamochi.user.service.UserService; import com.magamochi.user.service.UserService;
import com.magamochi.userinteraction.model.repository.UserFavoriteMangaRepository;
import com.magamochi.userinteraction.model.repository.UserMangaFollowRepository;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;

View File

@ -77,7 +77,7 @@ public class MyAnimeListService {
} }
private MangaStatus mapStatus(String malStatus) { private MangaStatus mapStatus(String malStatus) {
return switch (malStatus) { return switch (malStatus.toLowerCase()) {
case "finished" -> MangaStatus.COMPLETED; case "finished" -> MangaStatus.COMPLETED;
case "publishing" -> MangaStatus.ONGOING; case "publishing" -> MangaStatus.ONGOING;
case "on hiatus" -> MangaStatus.HIATUS; case "on hiatus" -> MangaStatus.HIATUS;

View File

@ -1,8 +1,9 @@
package com.magamochi.ingestion.providers; package com.magamochi.common;
public class ContentProviders { public class ContentProviders {
public static final String MANGA_LIVRE_BLOG = "Manga Livre Blog"; public static final String MANGA_LIVRE_BLOG = "Manga Livre Blog";
public static final String MANGA_LIVRE_TO = "Manga Livre.to"; public static final String MANGA_LIVRE_TO = "Manga Livre.to";
public static final String PINK_ROSA_SCAN = "Pink Rosa Scan"; public static final String PINK_ROSA_SCAN = "Pink Rosa Scan";
public static final String MANGA_DEX = "MangaDex"; public static final String MANGA_DEX = "MangaDex";
public static final String MANUAL_IMPORT = "Manual Import";
} }

View File

@ -6,7 +6,7 @@ import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange; import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -37,6 +37,9 @@ public class RabbitConfig {
@Value("${queues.image-fetch}") @Value("${queues.image-fetch}")
private String imageFetchQueue; private String imageFetchQueue;
@Value("${queues.file-import}")
private String fileImportQueue;
@Value("${topics.image-updates}") @Value("${topics.image-updates}")
private String imageUpdatesTopic; private String imageUpdatesTopic;
@ -68,6 +71,11 @@ public class RabbitConfig {
return new Queue(mangaCoverUpdateQueue, false); return new Queue(mangaCoverUpdateQueue, false);
} }
@Bean
public Queue fileImportQueue() {
return new Queue(fileImportQueue, false);
}
@Bean @Bean
public Binding bindingMangaCoverUpdateQueue( public Binding bindingMangaCoverUpdateQueue(
Queue mangaCoverUpdateQueue, TopicExchange imageUpdatesExchange) { Queue mangaCoverUpdateQueue, TopicExchange imageUpdatesExchange) {
@ -130,8 +138,8 @@ public class RabbitConfig {
} }
@Bean @Bean
public Jackson2JsonMessageConverter messageConverter() { public JacksonJsonMessageConverter messageConverter() {
return new Jackson2JsonMessageConverter(); return new JacksonJsonMessageConverter();
} }
@Bean @Bean

View File

@ -1,23 +1,33 @@
package com.magamochi.content.controller; 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.FileImportRequestDTO;
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.enumeration.ContentArchiveFileType;
import com.magamochi.content.service.ContentDownloadService;
import com.magamochi.content.service.ContentImportService;
import com.magamochi.content.service.ContentService; import com.magamochi.content.service.ContentService;
import com.magamochi.model.dto.MangaContentImagesDTO;
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;
private final ContentImportService contentImportService;
@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 +51,46 @@ 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());
}
@Operation(
summary = "Import multiple files",
description = "Accepts multiple content files via multipart/form-data and processes them.",
tags = {"Content"},
operationId = "importContentFiles")
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public DefaultResponseDTO<Void> importContentFiles(
@ModelAttribute FileImportRequestDTO requestDTO) {
contentImportService.importFiles(
requestDTO.malId(), requestDTO.aniListId(), requestDTO.files());
return DefaultResponseDTO.ok().build();
}
} }

View File

@ -0,0 +1,6 @@
package com.magamochi.content.model.dto;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
public record FileImportRequestDTO(String malId, String aniListId, List<MultipartFile> files) {}

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

@ -10,12 +10,12 @@ public record MangaContentDTO(
@NotNull Boolean downloaded, @NotNull Boolean downloaded,
@NotNull Boolean isRead, @NotNull Boolean isRead,
LanguageDTO language) { LanguageDTO language) {
public static MangaContentDTO from(MangaContent mangaContent) { public static MangaContentDTO from(MangaContent mangaContent, boolean isRead) {
return new MangaContentDTO( return new MangaContentDTO(
mangaContent.getId(), mangaContent.getId(),
mangaContent.getTitle(), mangaContent.getTitle(),
mangaContent.getDownloaded(), mangaContent.getDownloaded(),
false, isRead,
LanguageDTO.from(mangaContent.getLanguage())); LanguageDTO.from(mangaContent.getLanguage()));
} }
} }

View File

@ -1,4 +1,4 @@
package com.magamochi.model.dto; package com.magamochi.content.model.dto;
import com.magamochi.content.model.entity.MangaContent; import com.magamochi.content.model.entity.MangaContent;
import com.magamochi.content.model.entity.MangaContentImage; import com.magamochi.content.model.entity.MangaContentImage;

View File

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

View File

@ -0,0 +1,3 @@
package com.magamochi.content.queue.command;
public record FileImportCommand(long mangaContentProviderId, String filename) {}

View File

@ -0,0 +1,21 @@
package com.magamochi.content.queue.consumer;
import com.magamochi.content.queue.command.FileImportCommand;
import com.magamochi.content.service.ContentImportService;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class FileImportConsumer {
private final ContentImportService contentImportService;
@RabbitListener(queues = "${queues.file-import}")
public void receiveFileImportCommand(FileImportCommand command) {
log.info("Received file import command: {}", command);
contentImportService.importFile(command.mangaContentProviderId(), command.filename());
}
}

View File

@ -0,0 +1,23 @@
package com.magamochi.content.queue.producer;
import com.magamochi.content.queue.command.FileImportCommand;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class FileImportProducer {
private final RabbitTemplate rabbitTemplate;
@Value("${queues.file-import}")
private String fileImportQueue;
public void sendFileImportCommand(FileImportCommand command) {
rabbitTemplate.convertAndSend(fileImportQueue, command);
log.info("Sent file import command: {}", command);
}
}

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,35 +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 void markAsRead(Long chapterId) { var chapter = contentService.find(mangaContentId);
// TODO: implement this var chapterImages = chapter.getMangaContentImages();
// 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 = 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());
} }
@ -68,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();
} }
@ -77,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

@ -0,0 +1,151 @@
package com.magamochi.content.service;
import static java.util.Objects.isNull;
import static org.apache.commons.lang3.StringUtils.isBlank;
import com.magamochi.catalog.service.MangaContentProviderService;
import com.magamochi.catalog.service.MangaResolutionService;
import com.magamochi.common.exception.UnprocessableException;
import com.magamochi.common.model.enumeration.ContentType;
import com.magamochi.content.model.entity.MangaContentImage;
import com.magamochi.content.model.repository.MangaContentImageRepository;
import com.magamochi.content.queue.command.FileImportCommand;
import com.magamochi.content.queue.producer.FileImportProducer;
import com.magamochi.image.service.ImageFetchService;
import com.magamochi.image.service.ImageService;
import com.magamochi.image.service.S3Service;
import com.magamochi.ingestion.service.ContentProviderService;
import jakarta.validation.constraints.NotNull;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@Log4j2
@Service
@RequiredArgsConstructor
public class ContentImportService {
private final ContentProviderService contentProviderService;
private final MangaResolutionService mangaResolutionService;
private final MangaContentProviderService mangaContentProviderService;
private final ContentIngestService contentIngestService;
private final ImageFetchService imageFetchService;
private final S3Service s3Service;
private final FileImportProducer fileImportProducer;
private final MangaContentImageRepository mangaContentImageRepository;
private final ImageService imageService;
public void importFiles(String malId, String aniListId, @NotNull List<MultipartFile> files) {
if (isBlank(malId) && isBlank(aniListId)) {
throw new UnprocessableException("Either MyAnimeList or AniList IDs are required.");
}
if (files.isEmpty()) {
return;
}
var manga =
mangaResolutionService.findOrCreateManga(
isBlank(aniListId) ? null : Long.parseLong(aniListId),
isBlank(malId) ? null : Long.parseLong(malId));
var contentProvider = contentProviderService.findManualImportContentProvider();
var mangaContentProvider = mangaContentProviderService.findOrCreate(manga, contentProvider);
var sortedFiles = files.stream().sorted(Comparator.comparing(MultipartFile::getName)).toList();
sortedFiles.forEach(
file -> {
try {
var filename =
s3Service.uploadFile(
file.getBytes(),
file.getContentType(),
"temp/import/" + file.getOriginalFilename());
log.info("Temp file uploaded to S3: {}", filename);
fileImportProducer.sendFileImportCommand(
new FileImportCommand(mangaContentProvider.getId(), filename));
} catch (IOException e) {
throw new UnprocessableException("Failed to upload file to S3");
}
});
}
@Transactional
public void importFile(Long mangaContentProviderId, String filename) {
var mangaContent =
contentIngestService.ingest(
mangaContentProviderId,
removeImportPrefix(removeFileExtension(filename)),
null,
"en-US");
try {
var is = s3Service.getFileStream(filename);
var zis = new ZipInputStream(is);
ZipEntry entry;
var position = 0;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) {
continue;
}
var os = new ByteArrayOutputStream();
zis.transferTo(os);
var bytes = os.toByteArray();
var imageId = imageFetchService.uploadImage(bytes, null, ContentType.CONTENT_IMAGE);
var image = imageService.find(imageId);
mangaContentImageRepository.save(
MangaContentImage.builder()
.image(image)
.mangaContent(mangaContent)
.position(position)
.build());
zis.closeEntry();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
mangaContent.setDownloaded(true);
s3Service.deleteObjects(Set.of(filename));
}
private String removeFileExtension(String filename) {
if (isBlank(filename)) {
return filename;
}
int lastDotIndex = filename.lastIndexOf('.');
// No dot, or dot is the first character (like .gitignore)
if (lastDotIndex <= 0) {
return filename;
}
return filename.substring(0, lastDotIndex);
}
private String removeImportPrefix(String path) {
if (isNull(path)) {
return null;
}
return path.replace("temp/import/", "");
}
}

View File

@ -1,6 +1,7 @@
package com.magamochi.content.service; package com.magamochi.content.service;
import static java.util.Objects.isNull; import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import com.magamochi.catalog.service.LanguageService; import com.magamochi.catalog.service.LanguageService;
import com.magamochi.catalog.service.MangaContentProviderService; import com.magamochi.catalog.service.MangaContentProviderService;
@ -34,22 +35,24 @@ public class ContentIngestService {
private final ImageFetchProducer imageFetchProducer; private final ImageFetchProducer imageFetchProducer;
private final ImageService imageService; private final ImageService imageService;
public void ingest( public MangaContent ingest(
long mangaContentProviderId, long mangaContentProviderId,
@NotBlank String title, @NotBlank String title,
@NotBlank String url, String url,
@NotBlank String languageCode) { @NotBlank String languageCode) {
log.info("Ingesting Manga Content ({}) for provider {}", title, mangaContentProviderId); log.info("Ingesting Manga Content ({}) for provider {}", title, mangaContentProviderId);
var mangaContentProvider = mangaContentProviderService.find(mangaContentProviderId); var mangaContentProvider = mangaContentProviderService.find(mangaContentProviderId);
if (nonNull(url)) {
if (mangaContentRepository.existsByMangaContentProvider_IdAndUrlIgnoreCase( if (mangaContentRepository.existsByMangaContentProvider_IdAndUrlIgnoreCase(
mangaContentProvider.getId(), url)) { mangaContentProvider.getId(), url)) {
log.info( log.info(
"Manga Content ({}) for provider {} already exists. Skipped.", "Manga Content ({}) for provider {} already exists. Skipped.",
title, title,
mangaContentProviderId); mangaContentProviderId);
return; return null;
}
} }
var language = languageService.find(languageCode); var language = languageService.find(languageCode);
@ -68,6 +71,8 @@ public class ContentIngestService {
title, title,
mangaContentProviderId, mangaContentProviderId,
mangaContent.getId()); mangaContent.getId());
return mangaContent;
} }
@Transactional @Transactional

View File

@ -3,9 +3,10 @@ package com.magamochi.content.service;
import com.magamochi.catalog.service.MangaContentProviderService; import com.magamochi.catalog.service.MangaContentProviderService;
import com.magamochi.common.exception.NotFoundException; import com.magamochi.common.exception.NotFoundException;
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.entity.MangaContent; import com.magamochi.content.model.entity.MangaContent;
import com.magamochi.content.model.repository.MangaContentRepository; import com.magamochi.content.model.repository.MangaContentRepository;
import com.magamochi.model.dto.MangaContentImagesDTO; import com.magamochi.userinteraction.service.UserMangaContentReadService;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
@ -16,6 +17,7 @@ import org.springframework.stereotype.Service;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ContentService { public class ContentService {
private final MangaContentProviderService mangaContentProviderService; private final MangaContentProviderService mangaContentProviderService;
private final UserMangaContentReadService userMangaContentReadService;
private final MangaContentRepository mangaContentRepository; private final MangaContentRepository mangaContentRepository;
@ -24,7 +26,11 @@ public class ContentService {
return mangaContentProvider.getMangaContents().stream() return mangaContentProvider.getMangaContents().stream()
.sorted(Comparator.comparing(MangaContent::getId)) .sorted(Comparator.comparing(MangaContent::getId))
.map(MangaContentDTO::from) .map(
mangaContent -> {
var isRead = userMangaContentReadService.isRead(mangaContent.getId());
return MangaContentDTO.from(mangaContent, isRead);
})
.toList(); .toList();
} }

View File

@ -3,7 +3,6 @@ package com.magamochi.controller;
import com.magamochi.client.NtfyClient; import com.magamochi.client.NtfyClient;
import com.magamochi.common.model.dto.DefaultResponseDTO; import com.magamochi.common.model.dto.DefaultResponseDTO;
import com.magamochi.image.task.ImageCleanupTask; import com.magamochi.image.task.ImageCleanupTask;
import com.magamochi.ingestion.task.IngestFromContentProvidersTask;
import com.magamochi.task.MangaFollowUpdateTask; import com.magamochi.task.MangaFollowUpdateTask;
import com.magamochi.user.repository.UserRepository; import com.magamochi.user.repository.UserRepository;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@ -14,7 +13,6 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/management") @RequestMapping("/management")
@RequiredArgsConstructor @RequiredArgsConstructor
public class ManagementController { public class ManagementController {
private final IngestFromContentProvidersTask ingestFromContentProvidersTask;
private final ImageCleanupTask imageCleanupTask; private final ImageCleanupTask imageCleanupTask;
private final MangaFollowUpdateTask mangaFollowUpdateTask; private final MangaFollowUpdateTask mangaFollowUpdateTask;
private final UserRepository userRepository; private final UserRepository userRepository;

View File

@ -1,59 +0,0 @@
package com.magamochi.controller;
import com.magamochi.common.model.dto.DefaultResponseDTO;
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 = "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

@ -23,28 +23,4 @@ public class MangaController {
return DefaultResponseDTO.ok().build(); return DefaultResponseDTO.ok().build();
} }
@Operation(
summary = "Follow the manga specified by its ID",
description = "Follow the manga specified by its ID.",
tags = {"Manga"},
operationId = "followManga")
@PostMapping("/{mangaId}/followManga")
public DefaultResponseDTO<Void> followManga(@PathVariable Long mangaId) {
oldMangaService.follow(mangaId);
return DefaultResponseDTO.ok().build();
}
@Operation(
summary = "Unfollow the manga specified by its ID",
description = "Unfollow the manga specified by its ID.",
tags = {"Manga"},
operationId = "unfollowManga")
@PostMapping("/{mangaId}/unfollowManga")
public DefaultResponseDTO<Void> unfollowManga(@PathVariable Long mangaId) {
oldMangaService.unfollow(mangaId);
return DefaultResponseDTO.ok().build();
}
} }

View File

@ -6,15 +6,8 @@ import com.magamochi.model.dto.ImportRequestDTO;
// import com.magamochi.service.MangaImportService; // import com.magamochi.service.MangaImportService;
import com.magamochi.service.ProviderManualMangaImportService; import com.magamochi.service.ProviderManualMangaImportService;
import io.swagger.v3.oas.annotations.Operation; 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 jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController @RestController
@RequestMapping("/manga/import") @RequestMapping("/manga/import")
@ -34,29 +27,4 @@ public class MangaImportController {
return DefaultResponseDTO.ok( return DefaultResponseDTO.ok(
providerManualMangaImportService.importFromProvider(providerId, requestDTO)); providerManualMangaImportService.importFromProvider(providerId, requestDTO));
} }
@Operation(
summary = "Upload multiple files",
description = "Accepts multiple files via multipart/form-data and processes them.",
tags = {"Manga Import"},
operationId = "importMultipleFiles")
@PostMapping(
value = "/upload",
consumes = {"multipart/form-data"})
public DefaultResponseDTO<Void> uploadMultipleFiles(
@RequestPart("malId") @NotBlank String malId,
@Parameter(
description = "List of files to upload",
required = true,
content =
@Content(
mediaType = "multipart/form-data",
schema = @Schema(type = "array", format = "binary")))
@RequestPart("files")
@NotNull
List<MultipartFile> files) {
// mangaImportService.importMangaFiles(malId, files);
return DefaultResponseDTO.ok().build();
}
} }

View File

@ -1,38 +0,0 @@
package com.magamochi.controller;
import com.magamochi.common.model.dto.DefaultResponseDTO;
import com.magamochi.service.UserFavoriteMangaService;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/mangas")
@RequiredArgsConstructor
public class UserFavoriteMangaController {
private final UserFavoriteMangaService userFavoriteMangaService;
@Operation(
summary = "Favorite a manga",
description = "Set a manga as favorite for the logged user.",
tags = {"Favorite Mangas"},
operationId = "setFavorite")
@PostMapping("/{id}/favorite")
public DefaultResponseDTO<Void> setFavorite(@PathVariable Long id) {
userFavoriteMangaService.setFavorite(id);
return DefaultResponseDTO.ok().build();
}
@Operation(
summary = "Unfavorite a manga",
description = "Remove a manga from favorites for the logged user.",
tags = {"Favorite Mangas"},
operationId = "setUnfavorite")
@PostMapping("/{id}/unfavorite")
public DefaultResponseDTO<Void> setUnfavorite(@PathVariable Long id) {
userFavoriteMangaService.setUnfavorite(id);
return DefaultResponseDTO.ok().build();
}
}

View File

@ -37,19 +37,26 @@ public class ImageFetchService {
var imageBytes = response.body(); var imageBytes = response.body();
var fileContentType = resolveContentType(response, imageBytes); return uploadImage(imageBytes, response, contentType);
var fileHash = computeHash(imageBytes);
return imageManagerService.upload(
imageBytes, fileContentType, contentType.name().toLowerCase(), fileHash);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to fetch image from URL: {}", imageUrl, e); log.error("Failed to fetch image from URL: {}", imageUrl, e);
return null; return null;
} }
} }
public UUID uploadImage(
byte[] imageBytes, HttpResponse<byte[]> httpResponse, ContentType contentType)
throws NoSuchAlgorithmException {
var fileContentType = resolveContentType(httpResponse, imageBytes);
var fileHash = computeHash(imageBytes);
return imageManagerService.upload(
imageBytes, fileContentType, contentType.name().toLowerCase(), fileHash);
}
private String resolveContentType(HttpResponse<byte[]> response, byte[] fileBytes) { private String resolveContentType(HttpResponse<byte[]> response, byte[] fileBytes) {
if (nonNull(response)) {
var headerType = var headerType =
response response
.headers() .headers()
@ -60,6 +67,7 @@ public class ImageFetchService {
if (nonNull(headerType) && headerType.startsWith("image/")) { if (nonNull(headerType) && headerType.startsWith("image/")) {
return headerType; return headerType;
} }
}
return tika.detect(fileBytes); return tika.detect(fileBytes);
} }

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 +1,9 @@
package com.magamochi.ingestion.model.repository; package com.magamochi.ingestion.model.repository;
import com.magamochi.ingestion.model.entity.ContentProvider; import com.magamochi.ingestion.model.entity.ContentProvider;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface ContentProviderRepository extends JpaRepository<ContentProvider, Long> {} public interface ContentProviderRepository extends JpaRepository<ContentProvider, Long> {
Optional<ContentProvider> findByNameIgnoreCase(String name);
}

View File

@ -5,11 +5,11 @@ import static java.util.Objects.isNull;
import com.google.common.util.concurrent.RateLimiter; import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.catalog.model.entity.MangaContentProvider; import com.magamochi.catalog.model.entity.MangaContentProvider;
import com.magamochi.client.MangaDexClient; import com.magamochi.client.MangaDexClient;
import com.magamochi.common.ContentProviders;
import com.magamochi.common.exception.UnprocessableException; import com.magamochi.common.exception.UnprocessableException;
import com.magamochi.ingestion.model.dto.ContentImageInfoDTO; import com.magamochi.ingestion.model.dto.ContentImageInfoDTO;
import com.magamochi.ingestion.model.dto.ContentInfoDTO; import com.magamochi.ingestion.model.dto.ContentInfoDTO;
import com.magamochi.ingestion.providers.ContentProvider; import com.magamochi.ingestion.providers.ContentProvider;
import com.magamochi.ingestion.providers.ContentProviders;
import com.magamochi.ingestion.providers.ManualImportContentProvider; import com.magamochi.ingestion.providers.ManualImportContentProvider;
import java.util.*; import java.util.*;
import java.util.stream.IntStream; import java.util.stream.IntStream;

View File

@ -1,11 +1,11 @@
package com.magamochi.ingestion.providers.impl; package com.magamochi.ingestion.providers.impl;
import com.magamochi.catalog.model.entity.MangaContentProvider; import com.magamochi.catalog.model.entity.MangaContentProvider;
import com.magamochi.common.ContentProviders;
import com.magamochi.ingestion.model.dto.ContentImageInfoDTO; import com.magamochi.ingestion.model.dto.ContentImageInfoDTO;
import com.magamochi.ingestion.model.dto.ContentInfoDTO; import com.magamochi.ingestion.model.dto.ContentInfoDTO;
import com.magamochi.ingestion.model.dto.MangaInfoDTO; import com.magamochi.ingestion.model.dto.MangaInfoDTO;
import com.magamochi.ingestion.providers.ContentProvider; import com.magamochi.ingestion.providers.ContentProvider;
import com.magamochi.ingestion.providers.ContentProviders;
import com.magamochi.ingestion.providers.PagedContentProvider; import com.magamochi.ingestion.providers.PagedContentProvider;
import java.io.IOException; import java.io.IOException;
import java.util.*; import java.util.*;

View File

@ -3,11 +3,11 @@ package com.magamochi.ingestion.providers.impl;
import static java.util.Objects.nonNull; import static java.util.Objects.nonNull;
import com.magamochi.catalog.model.entity.MangaContentProvider; import com.magamochi.catalog.model.entity.MangaContentProvider;
import com.magamochi.common.ContentProviders;
import com.magamochi.ingestion.model.dto.ContentImageInfoDTO; import com.magamochi.ingestion.model.dto.ContentImageInfoDTO;
import com.magamochi.ingestion.model.dto.ContentInfoDTO; import com.magamochi.ingestion.model.dto.ContentInfoDTO;
import com.magamochi.ingestion.model.dto.MangaInfoDTO; import com.magamochi.ingestion.model.dto.MangaInfoDTO;
import com.magamochi.ingestion.providers.ContentProvider; import com.magamochi.ingestion.providers.ContentProvider;
import com.magamochi.ingestion.providers.ContentProviders;
import com.magamochi.ingestion.providers.PagedContentProvider; import com.magamochi.ingestion.providers.PagedContentProvider;
import com.magamochi.ingestion.service.FlareService; import com.magamochi.ingestion.service.FlareService;
import java.util.*; import java.util.*;

View File

@ -4,11 +4,11 @@ import static java.util.Objects.isNull;
import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.isBlank;
import com.magamochi.catalog.model.entity.MangaContentProvider; import com.magamochi.catalog.model.entity.MangaContentProvider;
import com.magamochi.common.ContentProviders;
import com.magamochi.ingestion.model.dto.ContentImageInfoDTO; import com.magamochi.ingestion.model.dto.ContentImageInfoDTO;
import com.magamochi.ingestion.model.dto.ContentInfoDTO; import com.magamochi.ingestion.model.dto.ContentInfoDTO;
import com.magamochi.ingestion.model.dto.MangaInfoDTO; import com.magamochi.ingestion.model.dto.MangaInfoDTO;
import com.magamochi.ingestion.providers.ContentProvider; import com.magamochi.ingestion.providers.ContentProvider;
import com.magamochi.ingestion.providers.ContentProviders;
import com.magamochi.ingestion.providers.PagedContentProvider; import com.magamochi.ingestion.providers.PagedContentProvider;
import com.magamochi.ingestion.service.FlareService; import com.magamochi.ingestion.service.FlareService;
import java.util.*; import java.util.*;

View File

@ -2,6 +2,7 @@ package com.magamochi.ingestion.service;
import static java.util.Objects.nonNull; import static java.util.Objects.nonNull;
import com.magamochi.common.ContentProviders;
import com.magamochi.common.exception.NotFoundException; import com.magamochi.common.exception.NotFoundException;
import com.magamochi.ingestion.model.dto.ContentProviderListDTO; import com.magamochi.ingestion.model.dto.ContentProviderListDTO;
import com.magamochi.ingestion.model.entity.ContentProvider; import com.magamochi.ingestion.model.entity.ContentProvider;
@ -32,4 +33,10 @@ public class ContentProviderService {
new NotFoundException( new NotFoundException(
"Content Provider not found (ID: " + contentProviderId + ").")); "Content Provider not found (ID: " + contentProviderId + ")."));
} }
public ContentProvider findManualImportContentProvider() {
return contentProviderRepository
.findByNameIgnoreCase(ContentProviders.MANUAL_IMPORT)
.orElseThrow(() -> new NotFoundException("Manual Import Content Provider not found"));
}
} }

View File

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

View File

@ -1,438 +0,0 @@
// package com.magamochi.service;
//
// import static java.util.Objects.isNull;
// import static java.util.Objects.nonNull;
//
// import com.google.common.util.concurrent.RateLimiter;
// import com.magamochi.catalog.model.entity.Genre;
// import com.magamochi.catalog.model.repository.GenreRepository;
// import com.magamochi.catalog.client.AniListClient;
// import com.magamochi.catalog.client.JikanClient;
// import com.magamochi.common.exception.NotFoundException;
// import com.magamochi.ingestion.model.entity.ContentProvider;
// import com.magamochi.model.dto.ContentProviderMangaChapterResponseDTO;
// import com.magamochi.model.entity.*;
// import com.magamochi.model.repository.*;
// import com.magamochi.catalog.util.DoubleUtil;
// import java.io.*;
// import java.net.URI;
// import java.net.URISyntaxException;
// import java.net.URL;
// import java.time.OffsetDateTime;
// import java.time.ZoneOffset;
// import java.util.ArrayList;
// import java.util.Comparator;
// import java.util.List;
// import java.util.stream.IntStream;
// import java.util.zip.ZipEntry;
// import java.util.zip.ZipInputStream;
// import lombok.RequiredArgsConstructor;
// import lombok.extern.log4j.Log4j2;
// import org.apache.commons.lang3.StringUtils;
// import org.springframework.stereotype.Service;
// import org.springframework.web.multipart.MultipartFile;
//
// @Log4j2
//// @Service
// @RequiredArgsConstructor
// public class MangaImportService {
// private final ProviderService providerService;
// private final MangaCreationService mangaCreationService;
// private final ImageService imageService;
// private final LanguageService languageService;
//
// private final GenreRepository genreRepository;
// private final MangaGenreRepository mangaGenreRepository;
// private final MangaContentProviderRepository mangaContentProviderRepository;
// private final AuthorRepository authorRepository;
// private final MangaAuthorRepository mangaAuthorRepository;
// private final MangaChapterRepository mangaChapterRepository;
// private final MangaRepository mangaRepository;
//
// private final JikanClient jikanClient;
// private final AniListClient aniListClient;
// private final MangaChapterImageRepository mangaChapterImageRepository;
// private final MangaAlternativeTitlesRepository mangaAlternativeTitlesRepository;
//
// private final RateLimiter jikanRateLimiter;
//
// public void importMangaFiles(String malId, List<MultipartFile> files) {
// log.info("Importing manga files for MAL ID {}", malId);
// var provider = providerService.getOrCreateProvider("Manual Import", false);
//
// jikanRateLimiter.acquire();
// var mangaData = jikanClient.getMangaById(Long.parseLong(malId));
//
// var mangaProvider = getOrCreateMangaProvider(mangaData.data().title(), provider);
//
// var sortedFiles =
// files.stream().sorted(Comparator.comparing(MultipartFile::getName)).toList();
//
// IntStream.rangeClosed(1, sortedFiles.size())
// .forEach(
// fileIndex -> {
// var file = sortedFiles.get(fileIndex - 1);
// log.info(
// "Importing file {}/{}: {}, for Mangá {}",
// fileIndex,
// sortedFiles.size(),
// file.getOriginalFilename(),
// mangaProvider.getManga().getTitle());
//
// var chapter =
// persistMangaChapter(
// mangaProvider,
// new ContentProviderMangaChapterResponseDTO(
// removeFileExtension(file.getOriginalFilename()),
// "manual_" + file.getOriginalFilename(),
// file.getOriginalFilename(),
// "en-US"));
//
// List<MangaChapterImage> allChapterImages = new ArrayList<>();
// try (InputStream is = file.getInputStream();
// ZipInputStream zis = new ZipInputStream(is)) {
// ZipEntry entry;
// var position = 0;
//
// while ((entry = zis.getNextEntry()) != null) {
// if (entry.isDirectory()) {
// continue;
// }
//
// var os = new ByteArrayOutputStream();
// zis.transferTo(os);
// var bytes = os.toByteArray();
//
// var image =
// imageService.uploadImage(bytes, "image/jpeg", "chapter/" + chapter.getId());
//
// var chapterImage =
// MangaChapterImage.builder()
// .position(position++)
// .image(image)
// .mangaChapter(chapter)
// .build();
//
// allChapterImages.add(chapterImage);
// zis.closeEntry();
// }
//
// log.info("Chapter images added for chapter {}", chapter.getTitle());
// } catch (IOException e) {
// throw new RuntimeException(e);
// }
//
// mangaChapterImageRepository.saveAll(allChapterImages);
// chapter.setDownloaded(true);
// mangaChapterRepository.save(chapter);
// });
//
// log.info("Import manga files for MAL ID {} completed.", malId);
// }
//
// public void updateMangaData(Long mangaId) {
// var manga =
// mangaRepository
// .findById(mangaId)
// .orElseThrow(() -> new NotFoundException("Manga not found for ID: " + mangaId));
//
// updateMangaData(manga);
// }
//
// public void updateMangaData(Manga manga) {
// log.info("Updating manga {}", manga.getTitle());
//
// if (nonNull(manga.getMalId())) {
// try {
// updateFromJikan(manga);
// return;
// } catch (Exception e) {
// log.warn(
// "Error updating manga data from Jikan for manga {}. Trying AniList... Error: {}",
// manga.getTitle(),
// e.getMessage());
// }
// }
//
// if (nonNull(manga.getAniListId())) {
// try {
// updateFromAniList(manga);
// return;
// } catch (Exception e) {
// log.warn(
// "Error updating manga data from AniList for manga {}. Error: {}",
// manga.getTitle(),
// e.getMessage());
// }
// }
//
// log.warn(
// "Could not update manga data for {}. No provider data available/found.",
// manga.getTitle());
// }
//
// private void updateFromJikan(Manga manga) throws IOException, URISyntaxException {
// jikanRateLimiter.acquire();
// var mangaData = jikanClient.getMangaById(manga.getMalId());
//
// manga.setSynopsis(mangaData.data().synopsis());
// manga.setStatus(mangaData.data().status());
// manga.setScore(DoubleUtil.round((double) mangaData.data().score(), 2));
// manga.setPublishedFrom(mangaData.data().published().from());
// manga.setPublishedTo(mangaData.data().published().to());
// manga.setChapterCount(mangaData.data().chapters());
//
// var authors =
// mangaData.data().authors().stream()
// .map(
// authorData ->
// authorRepository
// .findByMalId(authorData.mal_id())
// .orElseGet(
// () ->
// authorRepository.save(
// Author.builder()
// .malId(authorData.mal_id())
// .name(authorData.name())
// .build())))
// .toList();
//
// updateMangaAuthors(manga, authors);
//
// var genres =
// mangaData.data().genres().stream()
// .map(
// genreData ->
// genreRepository
// .findByMalId(genreData.mal_id())
// .orElseGet(
// () ->
// genreRepository.save(
// Genre.builder()
// .malId(genreData.mal_id())
// .name(genreData.name())
// .build())))
// .toList();
//
// updateMangaGenres(manga, genres);
//
// if (isNull(manga.getCoverImage())) {
// downloadCoverImage(manga, mangaData.data().images().jpg().large_image_url());
// }
//
// var mangaEntity = mangaRepository.save(manga);
// var alternativeTitles =
// mangaData.data().title_synonyms().stream()
// .map(at -> MangaAlternativeTitle.builder().manga(mangaEntity).title(at).build())
// .toList();
// mangaAlternativeTitlesRepository.saveAll(alternativeTitles);
// }
//
// private void updateFromAniList(Manga manga) throws IOException, URISyntaxException {
// var query =
// """
// query ($id: Int) {
// Media (id: $id, type: MANGA) {
// startDate { year month day }
// endDate { year month day }
// description
// status
// averageScore
// chapters
// coverImage { large }
// genres
// staff {
// edges {
// role
// node {
// name {
// full
// }
// }
// }
// }
// }
// }
// """;
// var request =
// new AniListClient.GraphQLRequest(
// query, new AniListClient.GraphQLRequest.Variables(manga.getAniListId()));
// var media = aniListClient.getManga(request).data().Media();
//
// manga.setSynopsis(media.description());
// manga.setStatus(mapAniListStatus(media.status()));
// manga.setScore(DoubleUtil.round((double) media.averageScore() / 10, 2)); // 0-100 -> 0-10
// manga.setPublishedFrom(convertFuzzyDate(media.startDate()));
// manga.setPublishedTo(convertFuzzyDate(media.endDate()));
// manga.setChapterCount(media.chapters());
//
// var authors =
// media.staff().edges().stream()
// .filter(edge -> isAuthorRole(edge.role()))
// .map(edge -> edge.node().name().full())
// .distinct()
// .map(
// name ->
// authorRepository
// .findByName(name)
// .orElseGet(
// () -> authorRepository.save(Author.builder().name(name).build())))
// .toList();
//
// updateMangaAuthors(manga, authors);
//
// var genres =
// media.genres().stream()
// .map(
// name ->
// genreRepository
// .findByName(name)
// .orElseGet(() ->
// genreRepository.save(Genre.builder().name(name).build())))
// .toList();
//
// updateMangaGenres(manga, genres);
//
// if (isNull(manga.getCoverImage())) {
// downloadCoverImage(manga, media.coverImage().large());
// }
//
// mangaRepository.save(manga);
// }
//
// private boolean isAuthorRole(String role) {
// return role.equalsIgnoreCase("Story & Art")
// || role.equalsIgnoreCase("Story")
// || role.equalsIgnoreCase("Art");
// }
//
// private String mapAniListStatus(String status) {
// return switch (status) {
// case "RELEASING" -> "Publishing";
// case "FINISHED" -> "Finished";
// case "NOT_YET_RELEASED" -> "Not yet published";
// default -> "Unknown";
// };
// }
//
// private OffsetDateTime convertFuzzyDate(AniListClient.MangaResponse.Manga.FuzzyDate date) {
// if (isNull(date) || isNull(date.year())) {
// return null;
// }
// return OffsetDateTime.of(
// date.year(),
// isNull(date.month()) ? 1 : date.month(),
// isNull(date.day()) ? 1 : date.day(),
// 0,
// 0,
// 0,
// 0,
// ZoneOffset.UTC);
// }
//
// private void updateMangaAuthors(Manga manga, List<Author> authors) {
// var mangaAuthors =
// authors.stream()
// .map(
// author ->
// mangaAuthorRepository
// .findByMangaAndAuthor(manga, author)
// .orElseGet(
// () ->
// mangaAuthorRepository.save(
// MangaAuthor.builder().manga(manga).author(author).build())))
// .toList();
// manga.setMangaAuthors(mangaAuthors);
// }
//
// private void updateMangaGenres(Manga manga, List<Genre> genres) {
// var mangaGenres =
// genres.stream()
// .map(
// genre ->
// mangaGenreRepository
// .findByMangaAndGenre(manga, genre)
// .orElseGet(
// () ->
// mangaGenreRepository.save(
// MangaGenre.builder().manga(manga).genre(genre).build())))
// .toList();
// manga.setMangaGenres(mangaGenres);
// }
//
// private void downloadCoverImage(Manga manga, String imageUrl)
// throws IOException, URISyntaxException {
// var inputStream =
// new BufferedInputStream(new URL(new URI(imageUrl).toASCIIString()).openStream());
//
// var bytes = inputStream.readAllBytes();
//
// inputStream.close();
// var image = imageService.uploadImage(bytes, "image/jpeg", "cover");
//
// manga.setCoverImage(image);
// }
//
// public MangaChapter persistMangaChapter(
// MangaContentProvider mangaContentProvider, ContentProviderMangaChapterResponseDTO chapter) {
// var mangaChapter =
// mangaChapterRepository
// .findByMangaContentProviderAndUrlIgnoreCase(mangaContentProvider,
// chapter.url())
// .orElseGet(MangaChapter::new);
//
// mangaChapter.setMangaContentProvider(mangaContentProvider);
// mangaChapter.setTitle(chapter.title());
// mangaChapter.setUrl(chapter.url());
//
// var language = languageService.getOrThrow(chapter.languageCode());
// mangaChapter.setLanguage(language);
//
// if (nonNull(chapter.chapter())) {
// try {
// mangaChapter.setChapterNumber(Integer.parseInt(chapter.chapter()));
// } catch (NumberFormatException e) {
// log.warn(
// "Could not parse chapter number {} from manga {}",
// chapter.chapter(),
// mangaContentProvider.getManga().getTitle());
// }
// }
//
// return mangaChapterRepository.save(mangaChapter);
// }
//
// private MangaContentProvider getOrCreateMangaProvider(
// String title, ContentProvider contentProvider) {
// return mangaContentProviderRepository
// .findByMangaTitleIgnoreCaseAndContentProvider(title, contentProvider)
// .orElseGet(
// () -> {
// jikanRateLimiter.acquire();
// var manga = mangaCreationService.getOrCreateManga(title, "manual", contentProvider);
//
// return mangaContentProviderRepository.save(
// MangaContentProvider.builder()
// .manga(manga)
// .mangaTitle(manga.getTitle())
// .contentProvider(contentProvider)
// .url("manual")
// .build());
// });
// }
//
// private String removeFileExtension(String filename) {
// if (StringUtils.isBlank(filename)) {
// return filename;
// }
//
// int lastDotIndex = filename.lastIndexOf('.');
//
// // No dot, or dot is the first character (like .gitignore)
// if (lastDotIndex <= 0) {
// return filename;
// }
//
// return filename.substring(0, lastDotIndex);
// }
// }

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,31 +1,21 @@
package com.magamochi.service; package com.magamochi.service;
import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.catalog.model.repository.MangaContentProviderRepository; import com.magamochi.catalog.model.repository.MangaContentProviderRepository;
import com.magamochi.catalog.model.repository.MangaRepository;
import com.magamochi.common.exception.NotFoundException; import com.magamochi.common.exception.NotFoundException;
import com.magamochi.content.model.entity.MangaContent; import com.magamochi.content.model.entity.MangaContent;
import com.magamochi.model.dto.*; import com.magamochi.model.dto.*;
import com.magamochi.model.entity.UserMangaFollow;
import com.magamochi.model.repository.*;
import com.magamochi.queue.MangaChapterDownloadProducer; import com.magamochi.queue.MangaChapterDownloadProducer;
import com.magamochi.user.service.UserService;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Log4j2 @Log4j2
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class OldMangaService { public class OldMangaService {
private final UserService userService;
private final MangaRepository mangaRepository;
private final MangaContentProviderRepository mangaContentProviderRepository; private final MangaContentProviderRepository mangaContentProviderRepository;
private final UserMangaFollowRepository userMangaFollowRepository;
private final MangaChapterDownloadProducer mangaChapterDownloadProducer; private final MangaChapterDownloadProducer mangaChapterDownloadProducer;
public void fetchAllNotDownloadedChapters(Long mangaProviderId) { public void fetchAllNotDownloadedChapters(Long mangaProviderId) {
@ -81,36 +71,4 @@ public class OldMangaService {
// mangaProvider.getContentProvider().getName()))); // mangaProvider.getContentProvider().getName())));
// } // }
public Manga findMangaByIdThrowIfNotFound(Long mangaId) {
return mangaRepository
.findById(mangaId)
.orElseThrow(() -> new NotFoundException("Manga not found for ID: " + mangaId));
}
@Transactional
public void follow(Long mangaId) {
var user = userService.getLoggedUserThrowIfNotFound();
var manga = findMangaByIdThrowIfNotFound(mangaId);
manga.setFollow(true);
if (userMangaFollowRepository.existsByUserAndManga(user, manga)) {
return;
}
userMangaFollowRepository.save(UserMangaFollow.builder().user(user).manga(manga).build());
}
@Transactional
public void unfollow(Long mangaId) {
var user = userService.getLoggedUserThrowIfNotFound();
var manga = findMangaByIdThrowIfNotFound(mangaId);
var userMangaFollow = userMangaFollowRepository.findByUserAndManga(user, manga);
userMangaFollow.ifPresent(userMangaFollowRepository::delete);
if (!userMangaFollowRepository.existsByManga(manga)) {
manga.setFollow(false);
}
}
} }

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);
}
}

View File

@ -1,50 +0,0 @@
package com.magamochi.service;
import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.catalog.model.repository.MangaRepository;
import com.magamochi.common.exception.NotFoundException;
import com.magamochi.model.entity.UserFavoriteManga;
import com.magamochi.model.repository.UserFavoriteMangaRepository;
import com.magamochi.user.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserFavoriteMangaService {
private final UserService userService;
private final MangaRepository mangaRepository;
private final UserFavoriteMangaRepository userFavoriteMangaRepository;
public void setFavorite(Long id) {
var user = userService.getLoggedUserThrowIfNotFound();
var manga = findMangaByIdThrowIfNotFound(id);
if (userFavoriteMangaRepository.existsByUserAndManga(user, manga)) {
return;
}
userFavoriteMangaRepository.save(UserFavoriteManga.builder().user(user).manga(manga).build());
}
public void setUnfavorite(Long id) {
var user = userService.getLoggedUserThrowIfNotFound();
var manga = findMangaByIdThrowIfNotFound(id);
var favoriteManga =
userFavoriteMangaRepository
.findByUserAndManga(user, manga)
.orElseThrow(
() ->
new NotFoundException(
"Error while trying to unfavorite manga. Please try again later."));
userFavoriteMangaRepository.delete(favoriteManga);
}
private Manga findMangaByIdThrowIfNotFound(Long mangaId) {
return mangaRepository
.findById(mangaId)
.orElseThrow(() -> new NotFoundException("Manga not found for ID: " + mangaId));
}
}

View File

@ -7,7 +7,6 @@ import com.magamochi.queue.UpdateMangaFollowChapterListProducer;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -22,7 +21,7 @@ public class MangaFollowUpdateTask {
private final UpdateMangaFollowChapterListProducer producer; private final UpdateMangaFollowChapterListProducer producer;
@Scheduled(cron = "${manga-follow.cron-expression}") // @Scheduled(cron = "${manga-follow.cron-expression}")
@Transactional @Transactional
public void updateMangaFollow() { public void updateMangaFollow() {
if (!updateEnabled) { if (!updateEnabled) {

View File

@ -1,7 +1,7 @@
package com.magamochi.user.model.entity; package com.magamochi.user.model.entity;
import com.magamochi.model.entity.UserFavoriteManga;
import com.magamochi.user.model.enumeration.UserRole; import com.magamochi.user.model.enumeration.UserRole;
import com.magamochi.userinteraction.model.entity.UserFavoriteManga;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.util.Set; import java.util.Set;
import lombok.*; import lombok.*;

View File

@ -0,0 +1,81 @@
package com.magamochi.userinteraction.controller;
import com.magamochi.common.model.dto.DefaultResponseDTO;
import com.magamochi.userinteraction.service.UserFavoriteMangaService;
import com.magamochi.userinteraction.service.UserMangaContentReadService;
import com.magamochi.userinteraction.service.UserMangaFollowService;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/user-interaction")
@RequiredArgsConstructor
public class UserInteractionController {
private final UserFavoriteMangaService userFavoriteMangaService;
private final UserMangaContentReadService userMangaContentReadService;
private final UserMangaFollowService userMangaFollowService;
@Operation(
summary = "Favorite a manga",
description = "Set a manga as favorite for the logged user.",
tags = {"User Interaction"},
operationId = "setFavorite")
@PostMapping("/manga/{mangaId}/favorite")
public DefaultResponseDTO<Void> setFavorite(@PathVariable Long mangaId) {
userFavoriteMangaService.setFavorite(mangaId);
return DefaultResponseDTO.ok().build();
}
@Operation(
summary = "Unfavorite a manga",
description = "Remove a manga from favorites for the logged user.",
tags = {"User Interaction"},
operationId = "setUnfavorite")
@PostMapping("/manga/{mangaId}/unfavorite")
public DefaultResponseDTO<Void> setUnfavorite(@PathVariable Long mangaId) {
userFavoriteMangaService.setUnfavorite(mangaId);
return DefaultResponseDTO.ok().build();
}
@Operation(
summary = "Mark content as read",
description = "Mark content as read by its ID.",
tags = {"User Interaction"},
operationId = "markContentAsRead")
@PostMapping("/content/{mangaContentId}/read")
public DefaultResponseDTO<Void> markContentAsRead(@PathVariable Long mangaContentId) {
userMangaContentReadService.setRead(mangaContentId);
return DefaultResponseDTO.ok().build();
}
@Operation(
summary = "Follow the manga specified by its ID",
description = "Follow the manga specified by its ID.",
tags = {"User Interaction"},
operationId = "followManga")
@PostMapping("/manga/{mangaId}/follow")
public DefaultResponseDTO<Void> followManga(@PathVariable Long mangaId) {
userMangaFollowService.follow(mangaId);
return DefaultResponseDTO.ok().build();
}
@Operation(
summary = "Unfollow the manga specified by its ID",
description = "Unfollow the manga specified by its ID.",
tags = {"User Interaction"},
operationId = "unfollowManga")
@PostMapping("/manga/{mangaId}/unfollow")
public DefaultResponseDTO<Void> unfollowManga(@PathVariable Long mangaId) {
userMangaFollowService.unfollow(mangaId);
return DefaultResponseDTO.ok().build();
}
}

View File

@ -1,4 +1,4 @@
package com.magamochi.model.entity; package com.magamochi.userinteraction.model.entity;
import com.magamochi.catalog.model.entity.Manga; import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.user.model.entity.User; import com.magamochi.user.model.entity.User;

View File

@ -0,0 +1,31 @@
package com.magamochi.userinteraction.model.entity;
import com.magamochi.content.model.entity.MangaContent;
import com.magamochi.user.model.entity.User;
import jakarta.persistence.*;
import java.time.Instant;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
@Entity
@Table(name = "user_manga_content_read")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class UserMangaContentRead {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
@ManyToOne
@JoinColumn(name = "manga_content_id")
private MangaContent mangaContent;
@CreationTimestamp private Instant createdAt;
}

View File

@ -1,4 +1,4 @@
package com.magamochi.model.entity; package com.magamochi.userinteraction.model.entity;
import com.magamochi.catalog.model.entity.Manga; import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.user.model.entity.User; import com.magamochi.user.model.entity.User;

View File

@ -1,8 +1,8 @@
package com.magamochi.model.repository; package com.magamochi.userinteraction.model.repository;
import com.magamochi.catalog.model.entity.Manga; import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.model.entity.UserFavoriteManga;
import com.magamochi.user.model.entity.User; import com.magamochi.user.model.entity.User;
import com.magamochi.userinteraction.model.entity.UserFavoriteManga;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

View File

@ -0,0 +1,13 @@
package com.magamochi.userinteraction.model.repository;
import com.magamochi.content.model.entity.MangaContent;
import com.magamochi.user.model.entity.User;
import com.magamochi.userinteraction.model.entity.UserMangaContentRead;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserMangaContentReadRepository extends JpaRepository<UserMangaContentRead, Long> {
boolean existsByUserAndMangaContent(User user, MangaContent mangaContent);
Optional<UserMangaContentRead> findByUserAndMangaContent(User user, MangaContent mangaContent);
}

View File

@ -1,8 +1,8 @@
package com.magamochi.model.repository; package com.magamochi.userinteraction.model.repository;
import com.magamochi.catalog.model.entity.Manga; import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.model.entity.UserMangaFollow;
import com.magamochi.user.model.entity.User; import com.magamochi.user.model.entity.User;
import com.magamochi.userinteraction.model.entity.UserMangaFollow;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

View File

@ -0,0 +1,37 @@
package com.magamochi.userinteraction.service;
import com.magamochi.catalog.service.MangaService;
import com.magamochi.user.service.UserService;
import com.magamochi.userinteraction.model.entity.UserFavoriteManga;
import com.magamochi.userinteraction.model.repository.UserFavoriteMangaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserFavoriteMangaService {
private final UserService userService;
private final MangaService mangaService;
private final UserFavoriteMangaRepository userFavoriteMangaRepository;
public void setFavorite(Long id) {
var user = userService.getLoggedUserThrowIfNotFound();
var manga = mangaService.find(id);
if (userFavoriteMangaRepository.existsByUserAndManga(user, manga)) {
return;
}
userFavoriteMangaRepository.save(UserFavoriteManga.builder().user(user).manga(manga).build());
}
public void setUnfavorite(Long id) {
var user = userService.getLoggedUserThrowIfNotFound();
var manga = mangaService.find(id);
var favoriteManga = userFavoriteMangaRepository.findByUserAndManga(user, manga);
favoriteManga.ifPresent(userFavoriteMangaRepository::delete);
}
}

View File

@ -0,0 +1,63 @@
package com.magamochi.userinteraction.service;
import com.magamochi.common.exception.NotFoundException;
import com.magamochi.content.model.entity.MangaContent;
import com.magamochi.content.model.repository.MangaContentRepository;
import com.magamochi.user.service.UserService;
import com.magamochi.userinteraction.model.entity.UserMangaContentRead;
import com.magamochi.userinteraction.model.repository.UserMangaContentReadRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class UserMangaContentReadService {
private final UserService userService;
private final UserMangaContentReadRepository userMangaContentReadRepository;
private final MangaContentRepository mangaContentRepository;
public void setRead(Long id) {
try {
var user = userService.getLoggedUserThrowIfNotFound();
var mangaContent = findMangaContent(id);
if (userMangaContentReadRepository.existsByUserAndMangaContent(user, mangaContent)) {
return;
}
userMangaContentReadRepository.save(
UserMangaContentRead.builder().user(user).mangaContent(mangaContent).build());
} catch (NotFoundException _) {
}
}
public void setUnread(Long id) {
var user = userService.getLoggedUserThrowIfNotFound();
var mangaContent = findMangaContent(id);
var mangaContentRead =
userMangaContentReadRepository.findByUserAndMangaContent(user, mangaContent);
mangaContentRead.ifPresent(userMangaContentReadRepository::delete);
}
public boolean isRead(Long id) {
try {
var user = userService.getLoggedUserThrowIfNotFound();
var mangaContent = findMangaContent(id);
return userMangaContentReadRepository.existsByUserAndMangaContent(user, mangaContent);
} catch (NotFoundException e) {
return false;
}
}
private MangaContent findMangaContent(Long id) {
return mangaContentRepository
.findById(id)
.orElseThrow(() -> new NotFoundException("MangaContent not found for ID: " + id));
}
}

View File

@ -0,0 +1,53 @@
package com.magamochi.userinteraction.service;
import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.catalog.model.repository.MangaRepository;
import com.magamochi.common.exception.NotFoundException;
import com.magamochi.user.service.UserService;
import com.magamochi.userinteraction.model.entity.UserMangaFollow;
import com.magamochi.userinteraction.model.repository.UserMangaFollowRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class UserMangaFollowService {
private final UserService userService;
private final MangaRepository mangaRepository;
private final UserMangaFollowRepository userMangaFollowRepository;
@Transactional
public void follow(Long mangaId) {
var user = userService.getLoggedUserThrowIfNotFound();
var manga = findMangaByIdThrowIfNotFound(mangaId);
manga.setFollow(true);
if (userMangaFollowRepository.existsByUserAndManga(user, manga)) {
return;
}
userMangaFollowRepository.save(UserMangaFollow.builder().user(user).manga(manga).build());
}
@Transactional
public void unfollow(Long mangaId) {
var user = userService.getLoggedUserThrowIfNotFound();
var manga = findMangaByIdThrowIfNotFound(mangaId);
var userMangaFollow = userMangaFollowRepository.findByUserAndManga(user, manga);
userMangaFollow.ifPresent(userMangaFollowRepository::delete);
if (!userMangaFollowRepository.existsByManga(manga)) {
manga.setFollow(false);
}
}
public Manga findMangaByIdThrowIfNotFound(Long mangaId) {
return mangaRepository
.findById(mangaId)
.orElseThrow(() -> new NotFoundException("Manga not found for ID: " + mangaId));
}
}

View File

@ -68,6 +68,7 @@ queues:
provider-page-ingest: ${PROVIDER_PAGE_INGEST_QUEUE:mangamochi.provider.page.ingest} provider-page-ingest: ${PROVIDER_PAGE_INGEST_QUEUE:mangamochi.provider.page.ingest}
image-fetch: ${IMAGE_FETCH_QUEUE:mangamochi.image.fetch} image-fetch: ${IMAGE_FETCH_QUEUE:mangamochi.image.fetch}
manga-cover-update: ${MANGA_COVER_UDPATE_QUEUE:mangamochi.manga.cover.update} manga-cover-update: ${MANGA_COVER_UDPATE_QUEUE:mangamochi.manga.cover.update}
file-import: ${FILE_IMPORT_QUEUE:mangamochi.file.import}
routing-key: routing-key:
image-update: ${IMAGE_UPDATE_ROUTING_KEY:mangamochi.image.update} image-update: ${IMAGE_UPDATE_ROUTING_KEY:mangamochi.image.update}

View File

@ -0,0 +1,8 @@
CREATE TABLE user_manga_content_read
(
id BIGSERIAL NOT NULL PRIMARY KEY,
manga_content_id BIGINT NOT NULL REFERENCES manga_contents (id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, manga_content_id)
);

View File

@ -0,0 +1,6 @@
ALTER TABLE content_providers
ALTER COLUMN url DROP NOT NULL;
INSERT INTO content_providers(name, url, active, supports_content_fetch, manual_import)
VALUES ('MangaDex', NULL, TRUE, TRUE, TRUE),
('Manual Import', NULL, TRUE, FALSE, FALSE);

View File

@ -0,0 +1,2 @@
ALTER TABLE manga_contents
ALTER COLUMN url DROP NOT NULL;

View File

@ -0,0 +1,3 @@
ALTER TABLE manga_content_provider
ALTER COLUMN url DROP NOT NULL,
ALTER COLUMN manga_title DROP NOT NULL;