Compare commits

...

2 Commits

17 changed files with 328 additions and 8 deletions

4
.env
View File

@ -2,8 +2,8 @@ DB_URL=jdbc:postgresql://localhost:5432/mangamochi?currentSchema=mangamochi
DB_USER=mangamochi DB_USER=mangamochi
DB_PASS=mangamochi DB_PASS=mangamochi
MINIO_ENDPOINT=http://omv.badger-pirarucu.ts.net:9000 MINIO_ENDPOINT=http://omv2.badger-pirarucu.ts.net:9000
MINIO_USER=admin MINIO_USER=rov
MINIO_PASS=!E9v4i0v3 MINIO_PASS=!E9v4i0v3
WEBSCRAPPER_ENDPOINT=http://mangamochi.badger-pirarucu.ts.net:8090/url WEBSCRAPPER_ENDPOINT=http://mangamochi.badger-pirarucu.ts.net:8090/url

View File

@ -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) {}
}

View File

@ -12,6 +12,7 @@ public class RabbitConfig {
public static final String MANGA_DATA_UPDATE_QUEUE = "mangaDataUpdateQueue"; public static final String MANGA_DATA_UPDATE_QUEUE = "mangaDataUpdateQueue";
public static final String MANGA_CHAPTER_DOWNLOAD_QUEUE = "mangaChapterDownloadQueue"; public static final String MANGA_CHAPTER_DOWNLOAD_QUEUE = "mangaChapterDownloadQueue";
public static final String MANGA_LIST_UPDATE_QUEUE = "mangaListUpdateQueue"; public static final String MANGA_LIST_UPDATE_QUEUE = "mangaListUpdateQueue";
public static final String MANGA_FOLLOW_UPDATE_CHAPTER_QUEUE = "mangaFollowUpdateChapterQueue";
@Bean @Bean
public Queue mangaDataUpdateQueue() { public Queue mangaDataUpdateQueue() {
@ -28,6 +29,11 @@ public class RabbitConfig {
return new Queue(MANGA_LIST_UPDATE_QUEUE, false); return new Queue(MANGA_LIST_UPDATE_QUEUE, false);
} }
@Bean
public Queue mangaFollowUpdateChapterQueue() {
return new Queue(MANGA_FOLLOW_UPDATE_CHAPTER_QUEUE, false);
}
@Bean @Bean
public Jackson2JsonMessageConverter messageConverter() { public Jackson2JsonMessageConverter messageConverter() {
return new Jackson2JsonMessageConverter(); return new Jackson2JsonMessageConverter();

View File

@ -1,6 +1,8 @@
package com.magamochi.mangamochi.controller; package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.client.NtfyClient;
import com.magamochi.mangamochi.model.dto.DefaultResponseDTO; 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.ImageCleanupTask;
import com.magamochi.mangamochi.task.UpdateMangaListTask; import com.magamochi.mangamochi.task.UpdateMangaListTask;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@ -13,6 +15,9 @@ import org.springframework.web.bind.annotation.*;
public class ManagementController { public class ManagementController {
private final UpdateMangaListTask updateMangaListTask; private final UpdateMangaListTask updateMangaListTask;
private final ImageCleanupTask imageCleanupTask; private final ImageCleanupTask imageCleanupTask;
private final UserRepository userRepository;
private final NtfyClient ntfyClient;
@Operation( @Operation(
summary = "Queue update manga list", summary = "Queue update manga list",
@ -37,4 +42,24 @@ public class ManagementController {
return DefaultResponseDTO.ok().build(); 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();
}
} }

View File

@ -74,4 +74,28 @@ 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) {
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();
}
} }

View File

@ -25,8 +25,10 @@ public record MangaDTO(
@NotNull List<String> authors, @NotNull List<String> authors,
@NotNull Double score, @NotNull Double score,
@NotNull List<MangaProviderDTO> providers, @NotNull List<MangaProviderDTO> providers,
@NotNull Integer chapterCount) { @NotNull Integer chapterCount,
public static MangaDTO from(Manga manga) { @NotNull Boolean favorite,
@NotNull Boolean following) {
public static MangaDTO from(Manga manga, Boolean favorite, Boolean following) {
return new MangaDTO( return new MangaDTO(
manga.getId(), manga.getId(),
manga.getTitle(), manga.getTitle(),
@ -43,7 +45,9 @@ public record MangaDTO(
.toList(), .toList(),
manga.getScore(), manga.getScore(),
manga.getMangaProviders().stream().map(MangaProviderDTO::from).toList(), manga.getMangaProviders().stream().map(MangaProviderDTO::from).toList(),
manga.getChapterCount()); manga.getChapterCount(),
favorite,
following);
} }
public record MangaProviderDTO( public record MangaProviderDTO(

View File

@ -0,0 +1,3 @@
package com.magamochi.mangamochi.model.dto;
public record UpdateMangaFollowChapterListCommand(Long mangaProviderId) {}

View File

@ -24,7 +24,6 @@ public class Manga {
private String title; private String title;
// @Enumerated(EnumType.STRING)
private String status; private String status;
private String synopsis; private String synopsis;
@ -59,4 +58,6 @@ public class Manga {
private List<MangaAlternativeTitle> alternativeTitles; private List<MangaAlternativeTitle> alternativeTitles;
private Integer chapterCount; private Integer chapterCount;
private Boolean follow;
} }

View File

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

View File

@ -1,6 +1,7 @@
package com.magamochi.mangamochi.model.repository; package com.magamochi.mangamochi.model.repository;
import com.magamochi.mangamochi.model.entity.Manga; import com.magamochi.mangamochi.model.entity.Manga;
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;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@ -9,5 +10,5 @@ public interface MangaRepository
extends JpaRepository<Manga, Long>, JpaSpecificationExecutor<Manga> { extends JpaRepository<Manga, Long>, JpaSpecificationExecutor<Manga> {
Optional<Manga> findByTitleIgnoreCase(String title); Optional<Manga> findByTitleIgnoreCase(String title);
Optional<Manga> findByMalId(Long malId); List<Manga> findByFollowTrue();
} }

View File

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

View File

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

View File

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

View File

@ -2,11 +2,13 @@ package com.magamochi.mangamochi.service;
import static java.util.Objects.nonNull; import static java.util.Objects.nonNull;
import com.magamochi.mangamochi.client.NtfyClient;
import com.magamochi.mangamochi.exception.NotFoundException; import com.magamochi.mangamochi.exception.NotFoundException;
import com.magamochi.mangamochi.model.dto.*; import com.magamochi.mangamochi.model.dto.*;
import com.magamochi.mangamochi.model.entity.Manga; import com.magamochi.mangamochi.model.entity.Manga;
import com.magamochi.mangamochi.model.entity.MangaChapter; import com.magamochi.mangamochi.model.entity.MangaChapter;
import com.magamochi.mangamochi.model.entity.MangaProvider; 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.repository.*;
import com.magamochi.mangamochi.model.specification.MangaSpecification; import com.magamochi.mangamochi.model.specification.MangaSpecification;
import com.magamochi.mangamochi.queue.MangaChapterDownloadProducer; 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.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Log4j2 @Log4j2
@Service @Service
@ -34,7 +37,12 @@ public class MangaService {
private final ContentProviderFactory contentProviderFactory; private final ContentProviderFactory contentProviderFactory;
private final UserFavoriteMangaRepository userFavoriteMangaRepository; private final UserFavoriteMangaRepository userFavoriteMangaRepository;
private final UserMangaFollowRepository userMangaFollowRepository;
private final MangaChapterDownloadProducer mangaChapterDownloadProducer; private final MangaChapterDownloadProducer mangaChapterDownloadProducer;
private final MangaChapterRepository mangaChapterRepository;
private final NtfyClient ntfyClient;
public void fetchAllNotDownloadedChapters(Long mangaProviderId) { public void fetchAllNotDownloadedChapters(Long mangaProviderId) {
var mangaProvider = var mangaProvider =
@ -87,8 +95,58 @@ public class MangaService {
public MangaDTO getManga(Long mangaId) { public MangaDTO getManga(Long mangaId) {
var manga = findMangaByIdThrowIfNotFound(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) { public void fetchMangaChapters(Long mangaProviderId) {
@ -114,4 +172,31 @@ public class MangaService {
.orElseThrow( .orElseThrow(
() -> new NotFoundException("Manga Provider not found for ID: " + mangaProviderId)); () -> 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);
}
}
} }

View File

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

View File

@ -44,6 +44,9 @@ minio:
secretKey: ${MINIO_PASS} secretKey: ${MINIO_PASS}
bucket: mangamochi bucket: mangamochi
ntfy:
endpoint: ${NTFY_ENDPOINT:https://ntfy.badger-pirarucu.ts.net}
jwt: jwt:
secret: /JcSdxjeyeuMGoK5GD9w7OfqK/j+nvHR7uVUY12pNis= secret: /JcSdxjeyeuMGoK5GD9w7OfqK/j+nvHR7uVUY12pNis=
expiration: 3600000 expiration: 3600000
@ -77,3 +80,7 @@ content-providers:
update-enabled: ${CONTENT_PROVIDER_UPDATE_ENABLED:false} update-enabled: ${CONTENT_PROVIDER_UPDATE_ENABLED:false}
cron-expression: "@weekly" cron-expression: "@weekly"
manga-follow:
update-enabled: ${CONTENT_PROVIDER_UPDATE_ENABLED:true}
cron-expression: "@daily"

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