Compare commits
2 Commits
e3ba04a087
...
03c273abb8
| Author | SHA1 | Date | |
|---|---|---|---|
| 03c273abb8 | |||
| aab2f52938 |
4
.env
4
.env
@ -2,8 +2,8 @@ DB_URL=jdbc:postgresql://localhost:5432/mangamochi?currentSchema=mangamochi
|
||||
DB_USER=mangamochi
|
||||
DB_PASS=mangamochi
|
||||
|
||||
MINIO_ENDPOINT=http://omv.badger-pirarucu.ts.net:9000
|
||||
MINIO_USER=admin
|
||||
MINIO_ENDPOINT=http://omv2.badger-pirarucu.ts.net:9000
|
||||
MINIO_USER=rov
|
||||
MINIO_PASS=!E9v4i0v3
|
||||
|
||||
WEBSCRAPPER_ENDPOINT=http://mangamochi.badger-pirarucu.ts.net:8090/url
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
package com.magamochi.mangamochi.client;
|
||||
|
||||
import io.github.resilience4j.retry.annotation.Retry;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@FeignClient(name = "ntfy", url = "${ntfy.endpoint}")
|
||||
@Retry(name = "JikanRetry")
|
||||
public interface NtfyClient {
|
||||
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
void notify(@RequestBody Request dto);
|
||||
|
||||
record Request(String topic, String message, String title) {}
|
||||
}
|
||||
@ -12,6 +12,7 @@ public class RabbitConfig {
|
||||
public static final String MANGA_DATA_UPDATE_QUEUE = "mangaDataUpdateQueue";
|
||||
public static final String MANGA_CHAPTER_DOWNLOAD_QUEUE = "mangaChapterDownloadQueue";
|
||||
public static final String MANGA_LIST_UPDATE_QUEUE = "mangaListUpdateQueue";
|
||||
public static final String MANGA_FOLLOW_UPDATE_CHAPTER_QUEUE = "mangaFollowUpdateChapterQueue";
|
||||
|
||||
@Bean
|
||||
public Queue mangaDataUpdateQueue() {
|
||||
@ -28,6 +29,11 @@ public class RabbitConfig {
|
||||
return new Queue(MANGA_LIST_UPDATE_QUEUE, false);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Queue mangaFollowUpdateChapterQueue() {
|
||||
return new Queue(MANGA_FOLLOW_UPDATE_CHAPTER_QUEUE, false);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Jackson2JsonMessageConverter messageConverter() {
|
||||
return new Jackson2JsonMessageConverter();
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package com.magamochi.mangamochi.controller;
|
||||
|
||||
import com.magamochi.mangamochi.client.NtfyClient;
|
||||
import com.magamochi.mangamochi.model.dto.DefaultResponseDTO;
|
||||
import com.magamochi.mangamochi.model.repository.UserRepository;
|
||||
import com.magamochi.mangamochi.task.ImageCleanupTask;
|
||||
import com.magamochi.mangamochi.task.UpdateMangaListTask;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@ -13,6 +15,9 @@ import org.springframework.web.bind.annotation.*;
|
||||
public class ManagementController {
|
||||
private final UpdateMangaListTask updateMangaListTask;
|
||||
private final ImageCleanupTask imageCleanupTask;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
private final NtfyClient ntfyClient;
|
||||
|
||||
@Operation(
|
||||
summary = "Queue update manga list",
|
||||
@ -37,4 +42,24 @@ public class ManagementController {
|
||||
|
||||
return DefaultResponseDTO.ok().build();
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Test notification",
|
||||
description = "Sends a test notification to all users",
|
||||
tags = {"Management"},
|
||||
operationId = "testNotification")
|
||||
@PostMapping("test-notification")
|
||||
public DefaultResponseDTO<Void> testNotification() {
|
||||
var users = userRepository.findAll();
|
||||
|
||||
users.forEach(
|
||||
user ->
|
||||
ntfyClient.notify(
|
||||
new NtfyClient.Request(
|
||||
"mangamochi/" + user.getId().toString(),
|
||||
"Mangamochi",
|
||||
"This is a test notification :)")));
|
||||
|
||||
return DefaultResponseDTO.ok().build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,4 +74,28 @@ public class MangaController {
|
||||
|
||||
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) {
|
||||
mangaService.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) {
|
||||
mangaService.unfollow(mangaId);
|
||||
|
||||
return DefaultResponseDTO.ok().build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,8 +25,10 @@ public record MangaDTO(
|
||||
@NotNull List<String> authors,
|
||||
@NotNull Double score,
|
||||
@NotNull List<MangaProviderDTO> providers,
|
||||
@NotNull Integer chapterCount) {
|
||||
public static MangaDTO from(Manga manga) {
|
||||
@NotNull Integer chapterCount,
|
||||
@NotNull Boolean favorite,
|
||||
@NotNull Boolean following) {
|
||||
public static MangaDTO from(Manga manga, Boolean favorite, Boolean following) {
|
||||
return new MangaDTO(
|
||||
manga.getId(),
|
||||
manga.getTitle(),
|
||||
@ -43,7 +45,9 @@ public record MangaDTO(
|
||||
.toList(),
|
||||
manga.getScore(),
|
||||
manga.getMangaProviders().stream().map(MangaProviderDTO::from).toList(),
|
||||
manga.getChapterCount());
|
||||
manga.getChapterCount(),
|
||||
favorite,
|
||||
following);
|
||||
}
|
||||
|
||||
public record MangaProviderDTO(
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
package com.magamochi.mangamochi.model.dto;
|
||||
|
||||
public record UpdateMangaFollowChapterListCommand(Long mangaProviderId) {}
|
||||
@ -24,7 +24,6 @@ public class Manga {
|
||||
|
||||
private String title;
|
||||
|
||||
// @Enumerated(EnumType.STRING)
|
||||
private String status;
|
||||
|
||||
private String synopsis;
|
||||
@ -59,4 +58,6 @@ public class Manga {
|
||||
private List<MangaAlternativeTitle> alternativeTitles;
|
||||
|
||||
private Integer chapterCount;
|
||||
|
||||
private Boolean follow;
|
||||
}
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
package com.magamochi.mangamochi.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_manga_follow")
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class UserMangaFollow {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "manga_id")
|
||||
private Manga manga;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package com.magamochi.mangamochi.model.repository;
|
||||
|
||||
import com.magamochi.mangamochi.model.entity.Manga;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
@ -9,5 +10,5 @@ public interface MangaRepository
|
||||
extends JpaRepository<Manga, Long>, JpaSpecificationExecutor<Manga> {
|
||||
Optional<Manga> findByTitleIgnoreCase(String title);
|
||||
|
||||
Optional<Manga> findByMalId(Long malId);
|
||||
List<Manga> findByFollowTrue();
|
||||
}
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
package com.magamochi.mangamochi.model.repository;
|
||||
|
||||
import com.magamochi.mangamochi.model.entity.Manga;
|
||||
import com.magamochi.mangamochi.model.entity.User;
|
||||
import com.magamochi.mangamochi.model.entity.UserMangaFollow;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface UserMangaFollowRepository extends JpaRepository<UserMangaFollow, Long> {
|
||||
boolean existsByUserAndManga(User user, Manga manga);
|
||||
|
||||
Optional<UserMangaFollow> findByUserAndManga(User user, Manga manga);
|
||||
|
||||
boolean existsByManga(Manga manga);
|
||||
|
||||
List<UserMangaFollow> findByUser(User user);
|
||||
|
||||
List<UserMangaFollow> findByManga(Manga manga);
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.magamochi.mangamochi.queue;
|
||||
|
||||
import com.magamochi.mangamochi.config.RabbitConfig;
|
||||
import com.magamochi.mangamochi.model.dto.UpdateMangaFollowChapterListCommand;
|
||||
import com.magamochi.mangamochi.service.MangaService;
|
||||
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 UpdateMangaFollowChapterListConsumer {
|
||||
private final MangaService mangaService;
|
||||
|
||||
@RabbitListener(queues = RabbitConfig.MANGA_FOLLOW_UPDATE_CHAPTER_QUEUE)
|
||||
public void receiveMangaFollowUpdateChapterCommand(UpdateMangaFollowChapterListCommand command) {
|
||||
log.info("Received update followed manga chapter list command: {}", command);
|
||||
mangaService.fetchFollowedMangaChapters(command.mangaProviderId());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.magamochi.mangamochi.queue;
|
||||
|
||||
import com.magamochi.mangamochi.config.RabbitConfig;
|
||||
import com.magamochi.mangamochi.model.dto.UpdateMangaFollowChapterListCommand;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Log4j2
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UpdateMangaFollowChapterListProducer {
|
||||
private final RabbitTemplate rabbitTemplate;
|
||||
|
||||
public void sendUpdateMangaFollowChapterListCommand(UpdateMangaFollowChapterListCommand command) {
|
||||
rabbitTemplate.convertAndSend(RabbitConfig.MANGA_FOLLOW_UPDATE_CHAPTER_QUEUE, command);
|
||||
log.info("Sent update followed manga chapter list command: {}", command);
|
||||
}
|
||||
}
|
||||
@ -2,11 +2,13 @@ package com.magamochi.mangamochi.service;
|
||||
|
||||
import static java.util.Objects.nonNull;
|
||||
|
||||
import com.magamochi.mangamochi.client.NtfyClient;
|
||||
import com.magamochi.mangamochi.exception.NotFoundException;
|
||||
import com.magamochi.mangamochi.model.dto.*;
|
||||
import com.magamochi.mangamochi.model.entity.Manga;
|
||||
import com.magamochi.mangamochi.model.entity.MangaChapter;
|
||||
import com.magamochi.mangamochi.model.entity.MangaProvider;
|
||||
import com.magamochi.mangamochi.model.entity.UserMangaFollow;
|
||||
import com.magamochi.mangamochi.model.repository.*;
|
||||
import com.magamochi.mangamochi.model.specification.MangaSpecification;
|
||||
import com.magamochi.mangamochi.queue.MangaChapterDownloadProducer;
|
||||
@ -21,6 +23,7 @@ import lombok.extern.log4j.Log4j2;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Log4j2
|
||||
@Service
|
||||
@ -34,7 +37,12 @@ public class MangaService {
|
||||
private final ContentProviderFactory contentProviderFactory;
|
||||
private final UserFavoriteMangaRepository userFavoriteMangaRepository;
|
||||
|
||||
private final UserMangaFollowRepository userMangaFollowRepository;
|
||||
|
||||
private final MangaChapterDownloadProducer mangaChapterDownloadProducer;
|
||||
private final MangaChapterRepository mangaChapterRepository;
|
||||
|
||||
private final NtfyClient ntfyClient;
|
||||
|
||||
public void fetchAllNotDownloadedChapters(Long mangaProviderId) {
|
||||
var mangaProvider =
|
||||
@ -87,8 +95,58 @@ public class MangaService {
|
||||
|
||||
public MangaDTO getManga(Long mangaId) {
|
||||
var manga = findMangaByIdThrowIfNotFound(mangaId);
|
||||
var user = userService.getLoggedUser();
|
||||
|
||||
return MangaDTO.from(manga);
|
||||
var favoriteMangasIds =
|
||||
nonNull(user)
|
||||
? userFavoriteMangaRepository.findByUser(user).stream()
|
||||
.map(ufm -> ufm.getManga().getId())
|
||||
.collect(Collectors.toSet())
|
||||
: Set.of();
|
||||
|
||||
var followingMangaIds =
|
||||
nonNull(user)
|
||||
? userMangaFollowRepository.findByUser(user).stream()
|
||||
.map(umf -> umf.getManga().getId())
|
||||
.collect(Collectors.toSet())
|
||||
: Set.of();
|
||||
|
||||
return MangaDTO.from(
|
||||
manga,
|
||||
favoriteMangasIds.contains(manga.getId()),
|
||||
followingMangaIds.contains(manga.getId()));
|
||||
}
|
||||
|
||||
public void fetchFollowedMangaChapters(Long mangaProviderId) {
|
||||
var mangaProvider =
|
||||
mangaProviderRepository
|
||||
.findById(mangaProviderId)
|
||||
.orElseThrow(
|
||||
() -> new NotFoundException("Manga Provider not found for ID: " + mangaProviderId));
|
||||
|
||||
var currentAvailableChapterCount =
|
||||
mangaChapterRepository.findByMangaProviderId(mangaProviderId).size();
|
||||
|
||||
fetchMangaChapters(mangaProviderId);
|
||||
mangaChapterRepository.flush();
|
||||
|
||||
var availableChapterCount =
|
||||
mangaChapterRepository.findByMangaProviderId(mangaProviderId).size();
|
||||
|
||||
if (availableChapterCount <= currentAvailableChapterCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("New chapters found for Manga Provider {}", mangaProviderId);
|
||||
|
||||
var userMangaFollows = userMangaFollowRepository.findByManga(mangaProvider.getManga());
|
||||
userMangaFollows.forEach(
|
||||
umf ->
|
||||
ntfyClient.notify(
|
||||
new NtfyClient.Request(
|
||||
"mangamochi/" + umf.getUser().getId().toString(),
|
||||
umf.getManga().getTitle(),
|
||||
"New chapter available on " + mangaProvider.getProvider().getName())));
|
||||
}
|
||||
|
||||
public void fetchMangaChapters(Long mangaProviderId) {
|
||||
@ -114,4 +172,31 @@ public class MangaService {
|
||||
.orElseThrow(
|
||||
() -> new NotFoundException("Manga Provider not found for ID: " + mangaProviderId));
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
package com.magamochi.mangamochi.task;
|
||||
|
||||
import com.magamochi.mangamochi.model.dto.UpdateMangaFollowChapterListCommand;
|
||||
import com.magamochi.mangamochi.model.entity.Manga;
|
||||
import com.magamochi.mangamochi.model.repository.MangaRepository;
|
||||
import com.magamochi.mangamochi.queue.UpdateMangaFollowChapterListProducer;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Log4j2
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MangaFollowUpdateTask {
|
||||
@Value("${manga-follow.update-enabled}")
|
||||
private Boolean updateEnabled;
|
||||
|
||||
private final MangaRepository mangaRepository;
|
||||
|
||||
private final UpdateMangaFollowChapterListProducer producer;
|
||||
|
||||
@Scheduled(cron = "${manga-follow.cron-expression}")
|
||||
@Transactional
|
||||
public void updateMangaFollow() {
|
||||
if (!updateEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateMangaList();
|
||||
}
|
||||
|
||||
public void updateMangaList() {
|
||||
log.info("Queuing followed manga updates...");
|
||||
|
||||
var followedMangas = mangaRepository.findByFollowTrue();
|
||||
followedMangas.forEach(this::updateFollowedManga);
|
||||
}
|
||||
|
||||
private void updateFollowedManga(Manga manga) {
|
||||
log.info("Fetching available mangas for followed Manga {}", manga.getTitle());
|
||||
|
||||
var mangaProviders = manga.getMangaProviders();
|
||||
mangaProviders.forEach(
|
||||
mangaProvider ->
|
||||
producer.sendUpdateMangaFollowChapterListCommand(
|
||||
new UpdateMangaFollowChapterListCommand(mangaProvider.getId())));
|
||||
|
||||
log.info("Followed Manga ({}) chapter list update queued.", manga.getTitle());
|
||||
}
|
||||
}
|
||||
@ -44,6 +44,9 @@ minio:
|
||||
secretKey: ${MINIO_PASS}
|
||||
bucket: mangamochi
|
||||
|
||||
ntfy:
|
||||
endpoint: ${NTFY_ENDPOINT:https://ntfy.badger-pirarucu.ts.net}
|
||||
|
||||
jwt:
|
||||
secret: /JcSdxjeyeuMGoK5GD9w7OfqK/j+nvHR7uVUY12pNis=
|
||||
expiration: 3600000
|
||||
@ -77,3 +80,7 @@ content-providers:
|
||||
update-enabled: ${CONTENT_PROVIDER_UPDATE_ENABLED:false}
|
||||
cron-expression: "@weekly"
|
||||
|
||||
manga-follow:
|
||||
update-enabled: ${CONTENT_PROVIDER_UPDATE_ENABLED:true}
|
||||
cron-expression: "@daily"
|
||||
|
||||
|
||||
9
src/main/resources/db/migration/V0016__MANGA_FOLLOW.sql
Normal file
9
src/main/resources/db/migration/V0016__MANGA_FOLLOW.sql
Normal file
@ -0,0 +1,9 @@
|
||||
ALTER TABLE mangas
|
||||
ADD COLUMN follow BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE user_manga_follow
|
||||
(
|
||||
id BIGSERIAL NOT NULL PRIMARY KEY,
|
||||
user_id BIGINT REFERENCES users (id),
|
||||
manga_id BIGINT REFERENCES mangas (id)
|
||||
);
|
||||
Loading…
x
Reference in New Issue
Block a user