feat: initial commit

This commit is contained in:
Rodrigo Verdiani 2025-10-21 13:19:28 -03:00
commit aa63fc66b8
93 changed files with 3628 additions and 0 deletions

9
.env Normal file
View File

@ -0,0 +1,9 @@
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_PASS=!E9v4i0v3
WEBSCRAPPER_ENDPOINT=http://localhost:8000/url

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf

250
.gitignore vendored Normal file
View File

@ -0,0 +1,250 @@
# Created by https://www.toptal.com/developers/gitignore/api/linux,intellij,jetbrains,java,react
# Edit at https://www.toptal.com/developers/gitignore?templates=linux,intellij,jetbrains,java,react
target/
### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### Intellij Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
# https://plugins.jetbrains.com/plugin/7973-sonarlint
.idea/**/sonarlint/
# SonarQube Plugin
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
.idea/**/sonarIssues.xml
# Markdown Navigator plugin
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
.idea/**/markdown-navigator.xml
.idea/**/markdown-navigator-enh.xml
.idea/**/markdown-navigator/
# Cache file creation bug
# See https://youtrack.jetbrains.com/issue/JBR-2257
.idea/$CACHE_FILE$
# CodeStream plugin
# https://plugins.jetbrains.com/plugin/12206-codestream
.idea/codestream.xml
# Azure Toolkit for IntelliJ plugin
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
.idea/**/azureSettings.xml
### Java ###
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
### JetBrains ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
# AWS User-specific
# Generated files
# Sensitive or high-churn files
# Gradle
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
# Mongo Explorer plugin
# File-based project format
# IntelliJ
# mpeltonen/sbt-idea plugin
# JIRA plugin
# Cursive Clojure plugin
# SonarLint plugin
# Crashlytics plugin (for Android Studio and IntelliJ)
# Editor-based Rest Client
# Android studio 3.1+ serialized cache file
### JetBrains Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
# https://plugins.jetbrains.com/plugin/7973-sonarlint
# SonarQube Plugin
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
# Markdown Navigator plugin
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
# Cache file creation bug
# See https://youtrack.jetbrains.com/issue/JBR-2257
# CodeStream plugin
# https://plugins.jetbrains.com/plugin/12206-codestream
# Azure Toolkit for IntelliJ plugin
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### react ###
.DS_*
logs
**/*.backup.*
**/*.back.*
node_modules
bower_components
*.sublime*
psd
thumb
sketch
# End of https://www.toptal.com/developers/gitignore/api/linux,intellij,jetbrains,java,react

27
HELP.md Normal file
View File

@ -0,0 +1,27 @@
# Getting Started
### Reference Documentation
For further reference, please consider the following sections:
* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html)
* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.6/maven-plugin)
* [Create an OCI image](https://docs.spring.io/spring-boot/3.5.6/maven-plugin/build-image.html)
* [Spring Data JPA](https://docs.spring.io/spring-boot/3.5.6/reference/data/sql.html#data.sql.jpa-and-spring-data)
* [Spring Web](https://docs.spring.io/spring-boot/3.5.6/reference/web/servlet.html)
* [Flyway Migration](https://docs.spring.io/spring-boot/3.5.6/how-to/data-initialization.html#howto.data-initialization.migration-tool.flyway)
### Guides
The following guides illustrate how to use some features concretely:
* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/)
* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/)
* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/)
* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/)
### Maven Parent overrides
Due to Maven's design, elements are inherited from the parent POM to the project POM.
While most of the inheritance is fine, it also inherits unwanted elements like `<license>` and `<developers>` from the parent.
To prevent this, the project POM contains empty overrides for these elements.
If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides.

58
docker-compose.yml Normal file
View File

@ -0,0 +1,58 @@
services:
mangamochi:
container_name: mangamochi
build:
context: .
dockerfile: Dockerfile
image: mangamochi:latest
depends_on:
- db
profiles:
- all
ports:
- "8080:8080"
networks:
- mangamochi-network
environment:
- DB_URL=jdbc:postgresql://db:5432/mangamochi?currentSchema=mangamochi
- DB_USER=mangamochi
- DB_PASS=mangamochi
- MINIO_ENDPOINT=http://omv.badger-pirarucu.ts.net:9000
- MINIO_USER=admin
- MINIO_PASS=!E9v4i0v3
db:
image: 'postgres:15'
container_name: mangamochi_db
environment:
- POSTGRES_USER=mangamochi
- POSTGRES_PASSWORD=mangamochi
profiles:
- all
- minimal
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- mangamochi-network
rabbit-mq:
image: 'rabbitmq:4-management'
container_name: mangamochi_rabbit-mq
profiles:
- all
- minimal
ports:
- 5672:5672
- 15672:15672
- 15692:15692
networks:
- mangamochi-network
networks:
mangamochi-network:
driver: bridge
volumes:
pgdata:

295
mvnw vendored Executable file
View File

@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

189
mvnw.cmd vendored Normal file
View File

@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

165
pom.xml Normal file
View File

@ -0,0 +1,165 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.6</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.magamochi</groupId>
<artifactId>mangamochi</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mangamochi</name>
<description>Demo project for Spring Boot</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.34.5</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.13</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<version>4.3.0</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.21.2</version>
</dependency>
<dependency>
<groupId>io.hypersistence</groupId>
<artifactId>hypersistence-utils-hibernate-63</artifactId>
<version>3.11.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.5.0-jre</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.13.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
<configuration>
<java>
<googleJavaFormat/>
</java>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,16 @@
package com.magamochi.mangamochi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableFeignClients
@EnableScheduling
public class MangamochiApplication {
public static void main(String[] args) {
SpringApplication.run(MangamochiApplication.class, args);
}
}

View File

@ -0,0 +1,17 @@
package com.magamochi.mangamochi.client;
import com.magamochi.mangamochi.model.dto.JikanMangaResponseDTO;
import com.magamochi.mangamochi.model.dto.JikanMangaSearchResponseDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "jikan", url = "https://api.jikan.moe/v4/manga")
public interface JikanClient {
@GetMapping
JikanMangaSearchResponseDTO mangaSearch(@RequestParam String q);
@GetMapping("/{id}")
JikanMangaResponseDTO getMangaById(@PathVariable Long id);
}

View File

@ -0,0 +1,13 @@
package com.magamochi.mangamochi.client;
import com.magamochi.mangamochi.model.dto.RapidFuzzRequestDTO;
import com.magamochi.mangamochi.model.dto.RapidFuzzResponseDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@FeignClient(name = "rapidFuzz", url = "http://127.0.0.1:9000/match-title")
public interface RapidFuzzClient {
@PostMapping
RapidFuzzResponseDTO mangaSearch(@RequestBody RapidFuzzRequestDTO dto);
}

View File

@ -0,0 +1,16 @@
package com.magamochi.mangamochi.client;
import com.magamochi.mangamochi.model.dto.WebScrapperClientRequestDTO;
import com.magamochi.mangamochi.model.dto.WebScrapperClientResponseDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@FeignClient(name = "web-scrapper", url = "${web-scrapper.endpoint}")
public interface WebScrapperClient {
@PostMapping(
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
WebScrapperClientResponseDTO scrape(@RequestBody WebScrapperClientRequestDTO dto);
}

View File

@ -0,0 +1,37 @@
package com.magamochi.mangamochi.config;
import java.net.URI;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
@Configuration
public class S3ClientConfig {
@Value("${minio.accessKey}")
private String accessKey;
@Value("${minio.secretKey}")
private String secretKey;
@Value("${minio.endpoint}")
private String endpoint;
@Bean
public S3Client s3Client() {
var credentials = AwsBasicCredentials.create(accessKey, secretKey);
var configuration = S3Configuration.builder().pathStyleAccessEnabled(true).build();
return S3Client.builder()
.endpointOverride(URI.create(endpoint))
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.region(Region.US_EAST_1)
.serviceConfiguration(configuration)
.build();
}
}

View File

@ -0,0 +1,91 @@
package com.magamochi.mangamochi.config;
import com.magamochi.mangamochi.security.JwtRequestFilter;
import com.magamochi.mangamochi.service.CustomUserDetailsService;
import com.magamochi.mangamochi.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService userDetailsService;
private final JwtUtil jwtUtil;
@Bean
public JwtRequestFilter jwtRequestFilter() {
return new JwtRequestFilter(jwtUtil, userDetailsService);
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/swagger-ui/**").permitAll()
.requestMatchers("/api-docs/**").permitAll()
.requestMatchers("/h2-console/**").permitAll()
.anyRequest().permitAll()
)
.headers(headers -> headers
.frameOptions(frame -> frame.disable())
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtRequestFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("http://localhost:3000"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View File

@ -0,0 +1,14 @@
package com.magamochi.mangamochi.config;
import lombok.NonNull;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(@NonNull CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("*").allowedMethods("*").allowedHeaders("*");
}
}

View File

@ -0,0 +1,64 @@
package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.AuthenticationRequestDTO;
import com.magamochi.mangamochi.model.dto.AuthenticationResponseDTO;
import com.magamochi.mangamochi.model.entity.User;
import com.magamochi.mangamochi.model.repository.UserRepository;
import com.magamochi.mangamochi.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
@CrossOrigin(origins = "*")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationManager authenticationManager;
private final UserDetailsService userDetailsService;
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@PostMapping("/login")
public ResponseEntity<?> createAuthenticationToken(
@RequestBody AuthenticationRequestDTO authenticationRequestDTO) {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
authenticationRequestDTO.username(), authenticationRequestDTO.password()));
} catch (BadCredentialsException e) {
return ResponseEntity.badRequest().body("Incorrect username or password");
}
final var userDetails =
userDetailsService.loadUserByUsername(authenticationRequestDTO.username());
final var jwt = jwtUtil.generateToken(userDetails);
var user = userRepository.findByUsername(userDetails.getUsername()).orElseThrow();
var role = user.getRole();
return ResponseEntity.ok(new AuthenticationResponseDTO(jwt, userDetails.getUsername(), role));
}
@PostMapping("/register")
public ResponseEntity<?> registerUser(
@RequestBody AuthenticationRequestDTO registrationRequestDTO) {
if (userRepository.existsByUsername(registrationRequestDTO.username())) {
return ResponseEntity.badRequest().body("Username is already taken");
}
userRepository.save(
User.builder()
.username(registrationRequestDTO.username())
.password(passwordEncoder.encode(registrationRequestDTO.password()))
.build());
return ResponseEntity.ok("User registered successfully");
}
}

View File

@ -0,0 +1,25 @@
package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.GenreDTO;
import com.magamochi.mangamochi.service.GenreService;
import io.swagger.v3.oas.annotations.Operation;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/genres")
@RequiredArgsConstructor
public class GenreController {
private final GenreService genreService;
@Operation(
summary = "Get a list of genres",
description = "Retrieve a list of genres.",
tags = {"Genre"},
operationId = "getGenres")
@GetMapping
public List<GenreDTO> getGenres() {
return genreService.getGenres();
}
}

View File

@ -0,0 +1,140 @@
package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.MangaChapterDTO;
import com.magamochi.mangamochi.model.dto.MangaChapterImagesDTO;
import com.magamochi.mangamochi.model.dto.MangaDTO;
import com.magamochi.mangamochi.model.dto.MangaListDTO;
import com.magamochi.mangamochi.model.enumeration.ArchiveFileType;
import com.magamochi.mangamochi.model.specification.MangaSpecification;
import com.magamochi.mangamochi.service.MangaService;
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 io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import java.io.IOException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/mangas")
@RequiredArgsConstructor
public class MangaController {
private final MangaService mangaService;
@Operation(
summary = "Get a list of mangas",
description = "Retrieve a list of mangas with their details.",
tags = {"Manga"},
operationId = "getMangas")
@GetMapping
public Page<MangaListDTO> getMangas(
@ParameterObject MangaSpecification specification,
@Parameter(hidden = true) @ParameterObject @PageableDefault Pageable pageable) {
return mangaService.getMangas(specification, pageable);
}
@Operation(
summary = "Get the details of a manga",
tags = {"Manga"},
operationId = "getManga")
@GetMapping("/{mangaId}")
public MangaDTO getManga(@PathVariable Long mangaId) {
return mangaService.getManga(mangaId);
}
@Operation(
summary = "Get the available chapters for a specific manga/provider combination",
description = "Retrieve a list of manga chapters for a specific manga/provider combination.",
tags = {"Manga"},
operationId = "getMangaChapters")
@GetMapping("/{mangaProviderId}/chapters")
public List<MangaChapterDTO> getMangaChapters(@PathVariable Long mangaProviderId) {
return mangaService.getMangaChapters(mangaProviderId);
}
@Operation(
summary = "Fetch the available chapters for a specific manga/provider combination",
description = "Fetch a list of manga chapters for a specific manga/provider combination.",
tags = {"Manga"},
operationId = "fetchMangaChapters")
@PostMapping("/{mangaProviderId}/fetch-chapters")
public void fetchMangaChapters(@PathVariable Long mangaProviderId) {
mangaService.fetchMangaChapters(mangaProviderId);
}
@Operation(summary = "Fetch chapter", operationId = "fetchChapter")
@PostMapping(value = "/chapter/{chapterId}/fetch")
public ResponseEntity<Void> fetchChapter(@PathVariable Long chapterId) {
mangaService.fetchChapter(chapterId);
return ResponseEntity.ok().build();
}
@Operation(
summary = "Get the available chapters for a specific manga/provider combination",
description = "Retrieve a list of manga chapters for a specific manga/provider combination.",
tags = {"Manga"},
operationId = "getMangaChapterImages")
@GetMapping("/{chapterId}/images")
public MangaChapterImagesDTO getMangaChapterImages(@PathVariable Long chapterId) {
return mangaService.getMangaChapterImages(chapterId);
}
@Operation(
summary = "Mark a chapter as read",
description = "Mark a chapter as read by its ID.",
tags = {"Manga"},
operationId = "markAsRead")
@PostMapping("/{chapterId}/mark-as-read")
public void markAsRead(@PathVariable Long chapterId) {
mangaService.markAsRead(chapterId);
}
@Operation(
summary = "Download all chapters for a manga provider",
operationId = "downloadAllChapters")
@PostMapping(value = "/chapter/{mangaProviderId}/download-all")
public ResponseEntity<Void> downloadAllChapters(@PathVariable Long mangaProviderId) {
mangaService.downloadAllChapters(mangaProviderId);
return ResponseEntity.ok().build();
}
@Operation(summary = "Download chapter archive", operationId = "downloadChapterArchive")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "Successful download",
content =
@Content(
mediaType = "application/octet-stream",
schema = @Schema(type = "string", format = "binary"))),
})
@PostMapping(
value = "/chapter/{chapterId}/download-archive",
produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<byte[]> downloadChapterArchive(
@PathVariable Long chapterId, @RequestParam ArchiveFileType archiveFileType)
throws IOException {
var response = mangaService.downloadChapter(chapterId, archiveFileType);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + response.filename() + "\"")
.body(response.content());
}
@Operation(summary = "Update manga info", operationId = "updateMangaInfo")
@PostMapping(value = "/manga/{mangaId}/info")
public ResponseEntity<Void> updateMangaInfo(@PathVariable Long mangaId) {
mangaService.updateInfo(mangaId);
return ResponseEntity.ok().build();
}
}

View File

@ -0,0 +1,3 @@
package com.magamochi.mangamochi.model.dto;
public record AuthenticationRequestDTO(String username, String password) {}

View File

@ -0,0 +1,3 @@
package com.magamochi.mangamochi.model.dto;
public record AuthenticationResponseDTO(String token, String username, String role) {}

View File

@ -0,0 +1,6 @@
package com.magamochi.mangamochi.model.dto;
import jakarta.validation.constraints.NotBlank;
public record ContentProviderMangaChapterResponseDTO(
@NotBlank String chapterTitle, @NotBlank String chapterUrl) {}

View File

@ -0,0 +1,7 @@
package com.magamochi.mangamochi.model.dto;
import com.magamochi.mangamochi.model.enumeration.MangaStatus;
import jakarta.validation.constraints.NotBlank;
public record ContentProviderMangaInfoResponseDTO(
@NotBlank String title, @NotBlank String url, String imgUrl, MangaStatus status) {}

View File

@ -0,0 +1,11 @@
package com.magamochi.mangamochi.model.dto;
import com.magamochi.mangamochi.model.entity.Genre;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record GenreDTO(@NotNull Long id, @NotBlank String name) {
public static GenreDTO from(Genre genre) {
return new GenreDTO(genre.getId(), genre.getName());
}
}

View File

@ -0,0 +1,28 @@
package com.magamochi.mangamochi.model.dto;
import java.time.OffsetDateTime;
import java.util.List;
public record JikanMangaResponseDTO(MangaData data) {
public record MangaData(
Long mal_id,
ImageData images,
List<String> title_synonyms,
String status,
boolean publishing,
String synopsis,
Double score,
PublishData published,
List<AuthorData> authors,
List<GenreData> genres) {
public record ImageData(ImageUrls jpg) {
public record ImageUrls(String large_image_url) {}
}
public record PublishData(OffsetDateTime from, OffsetDateTime to) {}
public record AuthorData(Long mal_id, String name) {}
public record GenreData(Long mal_id, String name) {}
}
}

View File

@ -0,0 +1,9 @@
package com.magamochi.mangamochi.model.dto;
import java.util.List;
public record JikanMangaSearchResponseDTO(List<MangaData> data) {
public record MangaData(Long mal_id, String title, List<TitleData> titles) {
public record TitleData(String type, String title) {}
}
}

View File

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

View File

@ -0,0 +1,19 @@
package com.magamochi.mangamochi.model.dto;
import com.magamochi.mangamochi.model.entity.MangaChapter;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record MangaChapterDTO(
@NotNull Long id,
@NotBlank String title,
@NotNull Boolean downloaded,
@NotNull Boolean isRead) {
public static MangaChapterDTO from(MangaChapter mangaChapter) {
return new MangaChapterDTO(
mangaChapter.getId(),
mangaChapter.getTitle(),
mangaChapter.getDownloaded(),
mangaChapter.getRead());
}
}

View File

@ -0,0 +1,23 @@
package com.magamochi.mangamochi.model.dto;
import com.magamochi.mangamochi.model.entity.MangaChapter;
import com.magamochi.mangamochi.model.entity.MangaChapterImage;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Comparator;
import java.util.List;
public record MangaChapterImagesDTO(
@NotNull Long id,
@NotBlank String mangaTitle,
@NotNull List<@NotBlank String> chapterImageKeys) {
public static MangaChapterImagesDTO from(MangaChapter mangaChapter) {
return new MangaChapterImagesDTO(
mangaChapter.getId(),
mangaChapter.getTitle(),
mangaChapter.getMangaChapterImages().stream()
.sorted(Comparator.comparing(MangaChapterImage::getPosition))
.map(mangaChapterImage -> mangaChapterImage.getImage().getFileKey())
.toList());
}
}

View File

@ -0,0 +1,10 @@
package com.magamochi.mangamochi.model.dto;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
public record MangaChapterResponseDTO(
@NotNull UUID id,
// @NotNull Long mangaProviderId,
@NotNull String chapterTitle,
@NotNull String chapterUrl) {}

View File

@ -0,0 +1,63 @@
package com.magamochi.mangamochi.model.dto;
import static java.util.Objects.isNull;
import com.magamochi.mangamochi.model.entity.Manga;
import com.magamochi.mangamochi.model.entity.MangaChapter;
import com.magamochi.mangamochi.model.entity.MangaProvider;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.OffsetDateTime;
import java.util.List;
public record MangaDTO(
@NotNull Long id,
@NotBlank String title,
String coverImageKey,
String status,
OffsetDateTime publishedFrom,
OffsetDateTime publishedTo,
String synopsis,
Integer providerCount,
@NotNull List<String> alternativeTitles,
@NotNull List<String> genres,
@NotNull List<String> authors,
@NotNull Double score,
@NotNull List<MangaProviderDTO> providers) {
public static MangaDTO from(Manga manga) {
return new MangaDTO(
manga.getId(),
manga.getTitle(),
isNull(manga.getCoverImage()) ? null : manga.getCoverImage().getFileKey(),
manga.getStatus(),
manga.getPublishedFrom(),
manga.getPublishedTo(),
manga.getSynopsis(),
manga.getMangaProviders().size(),
manga.getAlternativeTitles(),
manga.getMangaGenres().stream().map(mangaGenre -> mangaGenre.getGenre().getName()).toList(),
manga.getMangaAuthors().stream()
.map(mangaAuthor -> mangaAuthor.getAuthor().getName())
.toList(),
0.0,
manga.getMangaProviders().stream().map(MangaProviderDTO::from).toList());
}
public record MangaProviderDTO(
@NotNull long id,
@NotBlank String providerName,
@NotNull Integer chaptersAvailable,
@NotNull Integer chaptersDownloaded) {
public static MangaProviderDTO from(MangaProvider mangaProvider) {
var chapters = mangaProvider.getMangaChapters();
var chaptersAvailable = chapters.size();
var chaptersDownloaded = (int) chapters.stream().filter(MangaChapter::getDownloaded).count();
return new MangaProviderDTO(
mangaProvider.getId(),
mangaProvider.getProvider().getName(),
chaptersAvailable,
chaptersDownloaded);
}
}
}

View File

@ -0,0 +1,37 @@
package com.magamochi.mangamochi.model.dto;
import static java.util.Objects.nonNull;
import com.magamochi.mangamochi.model.entity.Manga;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.OffsetDateTime;
import java.util.List;
public record MangaListDTO(
@NotNull Long id,
@NotBlank String title,
String coverImageKey,
String status,
OffsetDateTime publishedFrom,
OffsetDateTime publishedTo,
Integer providerCount,
@NotNull List<String> genres,
@NotNull List<String> authors,
@NotNull Double score) {
public static MangaListDTO from(Manga manga) {
return new MangaListDTO(
manga.getId(),
manga.getTitle(),
nonNull(manga.getCoverImage()) ? manga.getCoverImage().getFileKey() : null,
manga.getStatus(),
manga.getPublishedFrom(),
manga.getPublishedTo(),
manga.getMangaProviders().size(),
manga.getMangaGenres().stream().map(mangaGenre -> mangaGenre.getGenre().getName()).toList(),
manga.getMangaAuthors().stream()
.map(mangaAuthor -> mangaAuthor.getAuthor().getName())
.toList(),
0.0);
}
}

View File

@ -0,0 +1,6 @@
package com.magamochi.mangamochi.model.dto;
import java.util.List;
public record MangaMessageDTO(
String contentProviderName, List<ContentProviderMangaInfoResponseDTO> mangaInfoResponseDTOs) {}

View File

@ -0,0 +1,5 @@
package com.magamochi.mangamochi.model.dto;
import java.util.List;
public record RapidFuzzRequestDTO(String title, List<String> options) {}

View File

@ -0,0 +1,3 @@
package com.magamochi.mangamochi.model.dto;
public record RapidFuzzResponseDTO(boolean match_found, String best_match, double similarity) {}

View File

@ -0,0 +1,3 @@
package com.magamochi.mangamochi.model.dto;
public record WebScrapperClientRequestDTO(String url) {}

View File

@ -0,0 +1,3 @@
package com.magamochi.mangamochi.model.dto;
public record WebScrapperClientResponseDTO(String page_source) {}

View File

@ -0,0 +1,32 @@
package com.magamochi.mangamochi.model.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.List;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
@Entity
@Table(name = "authors")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long malId;
private String name;
@CreationTimestamp private Instant createdAt;
@UpdateTimestamp private Instant updatedAt;
@OneToMany(mappedBy = "author")
private List<MangaAuthor> mangaAuthors;
}

View File

@ -0,0 +1,25 @@
package com.magamochi.mangamochi.model.entity;
import jakarta.persistence.*;
import java.util.List;
import lombok.*;
@Entity
@Table(name = "genres")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class Genre {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long malId;
private String name;
@OneToMany(mappedBy = "genre")
private List<MangaGenre> mangaGenres;
}

View File

@ -0,0 +1,27 @@
package com.magamochi.mangamochi.model.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
@Entity
@Table(name = "images")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class Image {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String fileKey;
@CreationTimestamp private Instant createdAt;
@UpdateTimestamp private Instant updatedAt;
}

View File

@ -0,0 +1,60 @@
package com.magamochi.mangamochi.model.entity;
import io.hypersistence.utils.hibernate.type.array.ListArrayType;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import org.hibernate.annotations.UpdateTimestamp;
@Entity
@Table(name = "mangas")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class Manga {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long malId;
private String title;
@Type(ListArrayType.class)
@Column(name = "alternative_titles", columnDefinition = "text[]")
private List<String> alternativeTitles;
// @Enumerated(EnumType.STRING)
private String status;
private String synopsis;
@CreationTimestamp private Instant createdAt;
@UpdateTimestamp private Instant updatedAt;
@OneToMany(mappedBy = "manga")
private List<MangaProvider> mangaProviders;
@ManyToOne
@JoinColumn(name = "cover_image_id")
private Image coverImage;
private Double score;
private OffsetDateTime publishedFrom;
private OffsetDateTime publishedTo;
@OneToMany(mappedBy = "manga")
private List<MangaAuthor> mangaAuthors;
@OneToMany(mappedBy = "manga")
private List<MangaGenre> mangaGenres;
}

View File

@ -0,0 +1,25 @@
package com.magamochi.mangamochi.model.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "manga_author")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class MangaAuthor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "manga_id")
private Manga manga;
@ManyToOne
@JoinColumn(name = "author_id")
private Author author;
}

View File

@ -0,0 +1,40 @@
package com.magamochi.mangamochi.model.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.List;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
@Entity
@Table(name = "manga_chapters")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class MangaChapter {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "manga_provider_id")
private MangaProvider mangaProvider;
private String title;
private String url;
@Builder.Default private Boolean downloaded = false;
@Builder.Default private Boolean read = false;
@CreationTimestamp private Instant createdAt;
@UpdateTimestamp private Instant updatedAt;
@OneToMany(mappedBy = "mangaChapter")
private List<MangaChapterImage> mangaChapterImages;
}

View File

@ -0,0 +1,34 @@
package com.magamochi.mangamochi.model.entity;
import jakarta.persistence.*;
import java.time.Instant;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
@Entity
@Table(name = "manga_chapter_images")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class MangaChapterImage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "manga_chapter_id")
private MangaChapter mangaChapter;
@OneToOne
@JoinColumn(name = "image_id")
private Image image;
private int position;
@CreationTimestamp private Instant createdAt;
@UpdateTimestamp private Instant updatedAt;
}

View File

@ -0,0 +1,25 @@
package com.magamochi.mangamochi.model.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "manga_genre")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class MangaGenre {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "manga_id")
private Manga manga;
@ManyToOne
@JoinColumn(name = "genre_id")
private Genre genre;
}

View File

@ -0,0 +1,29 @@
package com.magamochi.mangamochi.model.entity;
import jakarta.persistence.*;
import java.time.Instant;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
@Entity
@Table(name = "manga_import_reviews")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class MangaImportReview {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String url;
@ManyToOne
@JoinColumn(name = "provider_id")
private Provider provider;
@CreationTimestamp private Instant createdAt;
}

View File

@ -0,0 +1,40 @@
package com.magamochi.mangamochi.model.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.List;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
@Entity
@Table(name = "manga_provider")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class MangaProvider {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "manga_id", nullable = false)
private Manga manga;
@ManyToOne
@JoinColumn(name = "provider_id", nullable = false)
private Provider provider;
private String mangaTitle;
private String url;
@OneToMany(mappedBy = "mangaProvider")
List<MangaChapter> mangaChapters;
@CreationTimestamp private Instant createdAt;
@UpdateTimestamp private Instant updatedAt;
}

View File

@ -0,0 +1,34 @@
package com.magamochi.mangamochi.model.entity;
import com.magamochi.mangamochi.model.enumeration.ProviderStatus;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.List;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
@Entity
@Table(name = "providers")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class Provider {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Enumerated(EnumType.STRING)
private ProviderStatus status;
@CreationTimestamp private Instant createdAt;
@UpdateTimestamp private Instant updatedAt;
@OneToMany(mappedBy = "provider")
List<MangaProvider> mangaProviders;
}

View File

@ -0,0 +1,26 @@
package com.magamochi.mangamochi.model.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "users")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String role;
}

View File

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

View File

@ -0,0 +1,9 @@
package com.magamochi.mangamochi.model.enumeration;
public enum MangaStatus {
ONGOING,
COMPLETED,
HIATUS,
CANCELLED,
UNKNOWN
}

View File

@ -0,0 +1,6 @@
package com.magamochi.mangamochi.model.enumeration;
public enum ProviderStatus {
ACTIVE,
INACTIVE
}

View File

@ -0,0 +1,9 @@
package com.magamochi.mangamochi.model.repository;
import com.magamochi.mangamochi.model.entity.Author;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AuthorRepository extends JpaRepository<Author, Long> {
Optional<Author> findByMalId(Long aLong);
}

View File

@ -0,0 +1,9 @@
package com.magamochi.mangamochi.model.repository;
import com.magamochi.mangamochi.model.entity.Genre;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface GenreRepository extends JpaRepository<Genre, Long> {
Optional<Genre> findByMalId(Long malId);
}

View File

@ -0,0 +1,7 @@
package com.magamochi.mangamochi.model.repository;
import com.magamochi.mangamochi.model.entity.Image;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ImageRepository extends JpaRepository<Image, UUID> {}

View File

@ -0,0 +1,11 @@
package com.magamochi.mangamochi.model.repository;
import com.magamochi.mangamochi.model.entity.Author;
import com.magamochi.mangamochi.model.entity.Manga;
import com.magamochi.mangamochi.model.entity.MangaAuthor;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MangaAuthorRepository extends JpaRepository<MangaAuthor, Long> {
Optional<MangaAuthor> findByMangaAndAuthor(Manga manga, Author author);
}

View File

@ -0,0 +1,10 @@
package com.magamochi.mangamochi.model.repository;
import com.magamochi.mangamochi.model.entity.MangaChapter;
import com.magamochi.mangamochi.model.entity.MangaChapterImage;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MangaChapterImageRepository extends JpaRepository<MangaChapterImage, Long> {
List<MangaChapterImage> findAllByMangaChapter(MangaChapter mangaChapter);
}

View File

@ -0,0 +1,15 @@
package com.magamochi.mangamochi.model.repository;
import com.magamochi.mangamochi.model.entity.MangaChapter;
import com.magamochi.mangamochi.model.entity.MangaProvider;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MangaChapterRepository extends JpaRepository<MangaChapter, Long> {
Optional<MangaChapter> findByMangaProviderAndUrlIgnoreCase(
MangaProvider mangaProvider, @NotBlank String url);
List<MangaChapter> findByMangaProviderId(Long mangaProvider_id);
}

View File

@ -0,0 +1,9 @@
package com.magamochi.mangamochi.model.repository;
import com.magamochi.mangamochi.model.entity.*;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MangaGenreRepository extends JpaRepository<MangaGenre, Long> {
Optional<MangaGenre> findByMangaAndGenre(Manga manga, Genre genre);
}

View File

@ -0,0 +1,6 @@
package com.magamochi.mangamochi.model.repository;
import com.magamochi.mangamochi.model.entity.MangaImportReview;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MangaImportReviewRepository extends JpaRepository<MangaImportReview, Long> {}

View File

@ -0,0 +1,14 @@
package com.magamochi.mangamochi.model.repository;
import com.magamochi.mangamochi.model.entity.Manga;
import com.magamochi.mangamochi.model.entity.MangaProvider;
import com.magamochi.mangamochi.model.entity.Provider;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MangaProviderRepository extends JpaRepository<MangaProvider, Long> {
Optional<MangaProvider> findByMangaAndProvider(Manga manga, Provider provider);
Optional<MangaProvider> findByMangaTitleIgnoreCaseAndProvider(
String mangaTitle, Provider provider);
}

View File

@ -0,0 +1,13 @@
package com.magamochi.mangamochi.model.repository;
import com.magamochi.mangamochi.model.entity.Manga;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface MangaRepository
extends JpaRepository<Manga, Long>, JpaSpecificationExecutor<Manga> {
Optional<Manga> findByTitleIgnoreCase(String title);
Optional<Manga> findByMalId(Long malId);
}

View File

@ -0,0 +1,9 @@
package com.magamochi.mangamochi.model.repository;
import com.magamochi.mangamochi.model.entity.Provider;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProviderRepository extends JpaRepository<Provider, Long> {
Optional<Provider> findByNameIgnoreCase(String name);
}

View File

@ -0,0 +1,11 @@
package com.magamochi.mangamochi.model.repository;
import com.magamochi.mangamochi.model.entity.User;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
}

View File

@ -0,0 +1,61 @@
package com.magamochi.mangamochi.model.specification;
import static java.util.Objects.nonNull;
import com.magamochi.mangamochi.model.entity.Author;
import com.magamochi.mangamochi.model.entity.Manga;
import jakarta.persistence.criteria.*;
import java.util.ArrayList;
import java.util.List;
import lombok.NonNull;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.jpa.domain.Specification;
public record MangaSpecification(String searchQuery, List<Long> genreIds, List<String> statuses)
implements Specification<Manga> {
@Override
public Predicate toPredicate(
@NonNull Root<Manga> root, CriteriaQuery<?> query, @NonNull CriteriaBuilder criteriaBuilder) {
List<Predicate> predicates = new ArrayList<>();
if (StringUtils.isNotBlank(searchQuery)) {
var searchPattern = "%" + searchQuery.toLowerCase() + "%";
var titlePredicate =
criteriaBuilder.like(criteriaBuilder.lower(root.get("title")), searchPattern);
var authorsPredicate = searchInAuthors(root, query, criteriaBuilder, searchPattern);
var searchPredicate = criteriaBuilder.or(titlePredicate, authorsPredicate);
predicates.add(searchPredicate);
}
if (nonNull(genreIds) && !genreIds.isEmpty()) {
var genreJoin = root.join("mangaGenres", JoinType.LEFT);
predicates.add(genreJoin.get("genre").get("id").in(genreIds));
}
if (nonNull(statuses) && !statuses.isEmpty()) {
predicates.add(
criteriaBuilder
.lower(root.get("status"))
.in(statuses.stream().map(String::toLowerCase).toList()));
}
query.distinct(true);
return criteriaBuilder.and(predicates.toArray(Predicate[]::new));
}
private Predicate searchInAuthors(
Root<Manga> root,
CriteriaQuery<?> query,
CriteriaBuilder criteriaBuilder,
String searchPattern) {
Join<Manga, ?> mangaAuthorsJoin = root.join("mangaAuthors", JoinType.LEFT);
Join<Object, Author> authorJoin = mangaAuthorsJoin.join("author", JoinType.LEFT);
return criteriaBuilder.like(criteriaBuilder.lower(authorJoin.get("name")), searchPattern);
}
}

View File

@ -0,0 +1,57 @@
package com.magamochi.mangamochi.security;
import com.magamochi.mangamochi.service.CustomUserDetailsService;
import com.magamochi.mangamochi.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
public class JwtRequestFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization");
String username = null;
String jwt = null;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
try {
username = jwtUtil.extractUsername(jwt);
} catch (Exception e) {
logger.warn("JWT token validation failed", e);
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
chain.doFilter(request, response);
}
}

View File

@ -0,0 +1,31 @@
package com.magamochi.mangamochi.service;
import com.magamochi.mangamochi.model.repository.UserRepository;
import java.util.Collections;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
var user =
userRepository
.findByUsername(username)
.orElseThrow(
() -> new UsernameNotFoundException("User not found with username: " + username));
return new User(
user.getUsername(),
user.getPassword(),
Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + user.getRole())));
}
}

View File

@ -0,0 +1,19 @@
package com.magamochi.mangamochi.service;
import com.magamochi.mangamochi.model.dto.GenreDTO;
import com.magamochi.mangamochi.model.repository.GenreRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class GenreService {
private final GenreRepository genreRepository;
public List<GenreDTO> getGenres() {
var genres = genreRepository.findAll();
return genres.stream().map(GenreDTO::from).toList();
}
}

View File

@ -0,0 +1,24 @@
package com.magamochi.mangamochi.service;
import com.magamochi.mangamochi.model.entity.Image;
import com.magamochi.mangamochi.model.repository.ImageRepository;
import java.io.InputStream;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class ImageService {
private final S3Service s3Service;
private final ImageRepository imageRepository;
public Image uploadImage(byte[] data, String contentType, String path) {
var fileKey = s3Service.uploadFile(data, contentType, path);
return imageRepository.save(Image.builder().fileKey(fileKey).build());
}
public InputStream getImageStream(Image image) {
return s3Service.getFile(image.getFileKey());
}
}

View File

@ -0,0 +1,142 @@
package com.magamochi.mangamochi.service;
import static java.util.Objects.isNull;
import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.mangamochi.client.JikanClient;
import com.magamochi.mangamochi.client.RapidFuzzClient;
import com.magamochi.mangamochi.model.dto.ContentProviderMangaInfoResponseDTO;
import com.magamochi.mangamochi.model.dto.JikanMangaSearchResponseDTO;
import com.magamochi.mangamochi.model.dto.RapidFuzzRequestDTO;
import com.magamochi.mangamochi.model.entity.Manga;
import com.magamochi.mangamochi.model.entity.MangaImportReview;
import com.magamochi.mangamochi.model.entity.MangaProvider;
import com.magamochi.mangamochi.model.entity.Provider;
import com.magamochi.mangamochi.model.enumeration.ProviderStatus;
import com.magamochi.mangamochi.model.repository.MangaImportReviewRepository;
import com.magamochi.mangamochi.model.repository.MangaProviderRepository;
import com.magamochi.mangamochi.model.repository.MangaRepository;
import com.magamochi.mangamochi.model.repository.ProviderRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class MangaListService {
private final ProviderRepository providerRepository;
private final MangaRepository mangaRepository;
private final MangaProviderRepository mangaProviderRepository;
private final MangaImportReviewRepository mangaImportReviewRepository;
private final JikanClient jikanClient;
private final RapidFuzzClient rapidFuzzClient;
public void updateMangaList(
String contentProviderName, List<ContentProviderMangaInfoResponseDTO> mangaInfoResponseDTOs) {
var rateLimiter = RateLimiter.create(1);
var provider =
providerRepository
.findByNameIgnoreCase(contentProviderName)
.orElseGet(
() ->
providerRepository.save(
Provider.builder()
.name(contentProviderName)
.status(ProviderStatus.ACTIVE)
.build()));
mangaInfoResponseDTOs.forEach(
mangaResponse -> {
var mangaProvider =
mangaProviderRepository.findByMangaTitleIgnoreCaseAndProvider(
mangaResponse.title(), provider);
if (mangaProvider.isPresent()) {
return;
}
rateLimiter.acquire();
var manga = getOrCreateManga(mangaResponse.title(), mangaResponse.url(), provider);
if (isNull(manga)) {
return;
}
try {
mangaProviderRepository.save(
MangaProvider.builder()
.manga(manga)
.mangaTitle(mangaResponse.title())
.provider(provider)
.url(mangaResponse.url())
.build());
} catch (Exception e) {
log.error(e.getMessage());
}
});
}
private Manga getOrCreateManga(String title, String url, Provider provider) {
var existingManga = mangaRepository.findByTitleIgnoreCase(title);
if (existingManga.isPresent()) {
return existingManga.get();
}
var jikanResults = jikanClient.mangaSearch(title).data();
if (jikanResults.isEmpty()) {
createMangaImportReview(title, url, provider);
log.warn("No manga found with title {}", title);
return null;
}
var request =
new RapidFuzzRequestDTO(
title,
jikanResults.stream()
.flatMap(
results ->
results.titles().stream()
.map(JikanMangaSearchResponseDTO.MangaData.TitleData::title))
.toList());
var fuzzResults = rapidFuzzClient.mangaSearch(request);
if (!fuzzResults.match_found()) {
createMangaImportReview(title, url, provider);
log.warn("No match found for manga with title {}", title);
return null;
}
var resultOptional =
jikanResults.stream()
.filter(
results ->
results.titles().stream()
.map(JikanMangaSearchResponseDTO.MangaData.TitleData::title)
.toList()
.contains(fuzzResults.best_match()))
.findFirst();
if (resultOptional.isEmpty()) {
createMangaImportReview(title, url, provider);
log.warn("No match found for manga with title {}", title);
return null;
}
var result = resultOptional.get();
existingManga = mangaRepository.findByTitleIgnoreCase(result.title());
return existingManga.orElseGet(
() ->
mangaRepository.save(
Manga.builder().title(result.title()).malId(result.mal_id()).build()));
}
private void createMangaImportReview(String title, String url, Provider provider) {
mangaImportReviewRepository.save(
MangaImportReview.builder().title(title).url(url).provider(provider).build());
}
}

View File

@ -0,0 +1,239 @@
package com.magamochi.mangamochi.service;
import com.magamochi.mangamochi.client.JikanClient;
import com.magamochi.mangamochi.model.dto.*;
import com.magamochi.mangamochi.model.entity.MangaChapter;
import com.magamochi.mangamochi.model.entity.MangaChapterImage;
import com.magamochi.mangamochi.model.entity.MangaProvider;
import com.magamochi.mangamochi.model.enumeration.ArchiveFileType;
import com.magamochi.mangamochi.model.repository.MangaChapterImageRepository;
import com.magamochi.mangamochi.model.repository.MangaChapterRepository;
import com.magamochi.mangamochi.model.repository.MangaProviderRepository;
import com.magamochi.mangamochi.model.repository.MangaRepository;
import com.magamochi.mangamochi.model.specification.MangaSpecification;
import com.magamochi.mangamochi.service.providers.ContentProviderFactory;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.*;
import java.net.URL;
import java.util.Comparator;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import lombok.RequiredArgsConstructor;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class MangaService {
private final MangaChapterRepository mangaChapterRepository;
private final MangaRepository mangaRepository;
private final MangaProviderRepository mangaProviderRepository;
private final MangaChapterImageRepository mangaChapterImageRepository;
private final ImageService imageService;
private final JikanClient jikanClient;
private final ContentProviderFactory contentProviderFactory;
public Page<MangaListDTO> getMangas(MangaSpecification specification, Pageable pageable) {
return mangaRepository.findAll(specification, pageable).map(MangaListDTO::from);
}
public List<MangaChapterDTO> getMangaChapters(Long mangaProviderId) {
var mangaProvider =
mangaProviderRepository
.findById(mangaProviderId)
.orElseThrow(() -> new RuntimeException("manga provider not found"));
return mangaProvider.getMangaChapters().stream()
.sorted(Comparator.comparing(MangaChapter::getId))
.map(MangaChapterDTO::from)
.toList();
}
public void fetchChapter(Long chapterId) {
try {
var chapter =
mangaChapterRepository
.findById(chapterId)
.orElseThrow(() -> new RuntimeException("Chapter not found"));
var mangaProvider = chapter.getMangaProvider();
var provider =
contentProviderFactory.getContentProvider(mangaProvider.getProvider().getName());
var chapterImagesUrls = provider.getChapterImagesUrls(chapter.getUrl());
if (chapterImagesUrls.isEmpty()) {
throw new RuntimeException("Chapter image not found");
}
var chapterImages =
chapterImagesUrls.entrySet().stream()
.map(
entry -> {
try {
var inputStream =
new BufferedInputStream(
new URL(new URI(entry.getValue()).toASCIIString()).openStream());
var bytes = inputStream.readAllBytes();
inputStream.close();
var image =
imageService.uploadImage(bytes, "image/jpeg", "chapter/" + chapterId);
return MangaChapterImage.builder()
.mangaChapter(chapter)
.position(entry.getKey())
.image(image)
.build();
} catch (IOException | URISyntaxException e) {
throw new RuntimeException(e);
}
})
.toList();
mangaChapterImageRepository.saveAll(chapterImages);
chapter.setDownloaded(true);
mangaChapterRepository.save(chapter);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public MangaChapterArchiveDTO downloadChapter(Long chapterId, ArchiveFileType archiveFileType)
throws IOException {
var chapter =
mangaChapterRepository
.findById(chapterId)
.orElseThrow(() -> new RuntimeException("Chapter not found"));
var chapterImages = mangaChapterImageRepository.findAllByMangaChapter(chapter);
var byteArrayOutputStream =
switch (archiveFileType) {
case CBZ -> getChapterCbzArchive(chapterImages);
default -> throw new RuntimeException("Unsupported archive file type");
};
return new MangaChapterArchiveDTO(
chapter.getTitle() + ".cbz", byteArrayOutputStream.toByteArray());
}
private ByteArrayOutputStream getChapterCbzArchive(List<MangaChapterImage> chapterImages)
throws IOException {
var byteArrayOutputStream = new ByteArrayOutputStream();
var bufferedOutputStream = new BufferedOutputStream(byteArrayOutputStream);
var zipOutputStream = new ZipOutputStream(bufferedOutputStream);
var totalPages = chapterImages.size();
var paddingLength = String.valueOf(totalPages).length();
for (var pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
var imgSrc = chapterImages.get(pageNumber - 1);
var paddedFileName = String.format("%0" + paddingLength + "d.jpg", imgSrc.getPosition());
zipOutputStream.putNextEntry(new ZipEntry(paddedFileName));
IOUtils.copy(imageService.getImageStream(imgSrc.getImage()), zipOutputStream);
zipOutputStream.closeEntry();
}
zipOutputStream.finish();
zipOutputStream.flush();
IOUtils.closeQuietly(zipOutputStream);
return byteArrayOutputStream;
}
private void persistMangaChapters(
MangaProvider mangaProvider, ContentProviderMangaChapterResponseDTO chapter) {
var mangaChapter =
mangaChapterRepository
.findByMangaProviderAndUrlIgnoreCase(mangaProvider, chapter.chapterUrl())
.orElseGet(MangaChapter::new);
mangaChapter.setMangaProvider(mangaProvider);
mangaChapter.setTitle(chapter.chapterTitle());
mangaChapter.setUrl(chapter.chapterUrl());
mangaChapterRepository.save(mangaChapter);
}
public void downloadAllChapters(Long mangaProviderId) {
var mangaProvider =
mangaProviderRepository
.findById(mangaProviderId)
.orElseThrow(() -> new RuntimeException("Manga provider not found"));
var mangaChapters = mangaChapterRepository.findByMangaProviderId(mangaProviderId);
mangaChapters.forEach(mangaChapter -> fetchChapter(mangaChapter.getId()));
}
public void updateInfo(Long mangaId) {
var manga =
mangaRepository
.findById(mangaId)
.orElseThrow(() -> new RuntimeException("Manga not found"));
var mangaSearchResponse = jikanClient.mangaSearch(manga.getTitle());
if (mangaSearchResponse.data().isEmpty()) {
throw new RuntimeException("Manga not found");
}
// TODO: create logic to select appropriate manga
var mangaResponse = mangaSearchResponse.data().getFirst();
manga.setTitle(mangaResponse.title());
manga.setMalId(mangaResponse.mal_id());
mangaRepository.save(manga);
}
public MangaDTO getManga(Long mangaId) {
var manga =
mangaRepository
.findById(mangaId)
.orElseThrow(() -> new RuntimeException("Manga not found"));
return MangaDTO.from(manga);
}
public void fetchMangaChapters(Long mangaProviderId) {
var mangaProvider =
mangaProviderRepository
.findById(mangaProviderId)
.orElseThrow(() -> new RuntimeException("manga provider not found"));
var contentProvider =
contentProviderFactory.getContentProvider(mangaProvider.getProvider().getName());
var availableChapters = contentProvider.getAvailableChapters(mangaProvider);
availableChapters.forEach(chapter -> persistMangaChapters(mangaProvider, chapter));
}
public MangaChapterImagesDTO getMangaChapterImages(Long chapterId) {
var chapter =
mangaChapterRepository
.findById(chapterId)
.orElseThrow(() -> new RuntimeException("Chapter not found"));
return MangaChapterImagesDTO.from(chapter);
}
public void markAsRead(Long chapterId) {
var chapter =
mangaChapterRepository
.findById(chapterId)
.orElseThrow(() -> new RuntimeException("Chapter not found"));
chapter.setRead(true);
mangaChapterRepository.save(chapter);
}
}

View File

@ -0,0 +1,37 @@
package com.magamochi.mangamochi.service;
import java.io.InputStream;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
@Service
@RequiredArgsConstructor
public class S3Service {
@Value("${minio.bucket}")
private String bucket;
private final S3Client s3Client;
public String uploadFile(byte[] data, String contentType, String path) {
var filename = "manga/" + path + "/" + UUID.randomUUID();
var request =
PutObjectRequest.builder().bucket(bucket).key(filename).contentType(contentType).build();
s3Client.putObject(request, RequestBody.fromBytes(data));
return filename;
}
public InputStream getFile(String key) {
var request = GetObjectRequest.builder().bucket(bucket).key(key).build();
return s3Client.getObject(request);
}
}

View File

@ -0,0 +1,24 @@
package com.magamochi.mangamochi.service;
import com.magamochi.mangamochi.client.WebScrapperClient;
import com.magamochi.mangamochi.model.dto.WebScrapperClientRequestDTO;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class WebScrapperClientProxyService {
private final WebScrapperClient webScrapperClient;
public Document scrapeToJsoupDocument(String url) throws IOException {
var htmlContent = scrape(url);
return Jsoup.parse(htmlContent);
}
private String scrape(String url) {
return webScrapperClient.scrape(new WebScrapperClientRequestDTO(url)).page_source();
}
}

View File

@ -0,0 +1,15 @@
package com.magamochi.mangamochi.service.providers;
import com.magamochi.mangamochi.model.dto.ContentProviderMangaChapterResponseDTO;
import com.magamochi.mangamochi.model.dto.ContentProviderMangaInfoResponseDTO;
import com.magamochi.mangamochi.model.entity.MangaProvider;
import java.util.List;
import java.util.Map;
public interface ContentProvider {
List<ContentProviderMangaInfoResponseDTO> getAvailableMangas();
List<ContentProviderMangaChapterResponseDTO> getAvailableChapters(MangaProvider provider);
Map<Integer, String> getChapterImagesUrls(String chapterUrl);
}

View File

@ -0,0 +1,24 @@
package com.magamochi.mangamochi.service.providers;
import java.util.Map;
import java.util.Objects;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Getter
@Component
@RequiredArgsConstructor
public class ContentProviderFactory {
private final Map<String, ContentProvider> contentProviders;
public ContentProvider getContentProvider(String providerName) {
var provider = contentProviders.get(providerName);
if (Objects.isNull(provider)) {
throw new IllegalArgumentException("No such provider " + providerName);
}
return provider;
}
}

View File

@ -0,0 +1,6 @@
package com.magamochi.mangamochi.service.providers;
public class ContentProviders {
public static final String MANGA_LIVRE = "Manga Livre";
public static final String MANGA_LIVRE_BLOG = "Manga Livre Blog";
}

View File

@ -0,0 +1,170 @@
package com.magamochi.mangamochi.service.providers.impl;
import com.magamochi.mangamochi.model.dto.ContentProviderMangaChapterResponseDTO;
import com.magamochi.mangamochi.model.dto.ContentProviderMangaInfoResponseDTO;
import com.magamochi.mangamochi.model.entity.MangaProvider;
import com.magamochi.mangamochi.model.enumeration.MangaStatus;
import com.magamochi.mangamochi.service.WebScrapperClientProxyService;
import com.magamochi.mangamochi.service.providers.ContentProvider;
import com.magamochi.mangamochi.service.providers.ContentProviders;
import java.io.IOException;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.jsoup.nodes.Element;
import org.springframework.stereotype.Service;
@Log4j2
@Service(ContentProviders.MANGA_LIVRE_BLOG)
@RequiredArgsConstructor
public class MangaLivreBlogProvider implements ContentProvider {
private static final Pattern NUMERIC_PATTERN = Pattern.compile("-?\\d+");
private final String url = "https://mangalivre.blog/manga/";
private final WebScrapperClientProxyService webScrapperClientProxyService;
@Override
public List<ContentProviderMangaInfoResponseDTO> getAvailableMangas() {
var totalPages = getTotalPages();
if (Objects.isNull(totalPages) || totalPages < 1) {
return List.of();
}
return IntStream.rangeClosed(1, totalPages)
.mapToObj(this::getMangasFromPage)
.filter(Objects::nonNull)
.flatMap(List::stream)
.toList();
}
@Override
public List<ContentProviderMangaChapterResponseDTO> getAvailableChapters(
MangaProvider mangaProvider) {
try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(mangaProvider.getUrl());
var chapterList = document.getElementsByClass("chapters-list").getFirst();
var chapterItems = chapterList.getElementsByClass("chapter-item");
return chapterItems.stream()
.map(
chapterItemElement -> {
var chapterDetailsContainer =
chapterItemElement.getElementsByClass("chapter-details").getFirst();
var linkElement = chapterDetailsContainer.getElementsByTag("a").getFirst();
var chapterNumberElement =
linkElement.getElementsByClass("chapter-number").getFirst();
return new ContentProviderMangaChapterResponseDTO(
chapterNumberElement.text(), linkElement.attr("href"));
})
.toList();
} catch (IOException | NoSuchElementException e) {
log.error("Error fetching mangas from MangaLivre", e);
return List.of();
}
}
@Override
public Map<Integer, String> getChapterImagesUrls(String chapterUrl) {
try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(chapterUrl);
var chapterImageContainers = document.getElementsByClass("chapter-image-container");
var imageUrls =
chapterImageContainers.stream()
.map(
chapterImageContainerElement -> {
var pageNumber = chapterImageContainerElement.id();
var imageElement =
chapterImageContainerElement.getElementsByTag("img").getFirst();
return imageElement.attr("data-lazy-src");
})
.toList();
return IntStream.range(0, imageUrls.size())
.boxed()
.collect(
Collectors.toMap(
i -> i, imageUrls::get, (existing, replacement) -> existing, LinkedHashMap::new));
} catch (IOException | NoSuchElementException e) {
log.error("Error fetching mangas from MangaLivre", e);
return Map.of();
}
}
private List<ContentProviderMangaInfoResponseDTO> getMangasFromPage(int page) {
try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(url + "page/" + page);
var mangaGrid = document.getElementsByClass("manga-grid").getFirst();
var mangaElements = mangaGrid.getElementsByTag("article");
return mangaElements.stream()
.map(
element -> {
try {
var linkElement = element.getElementsByTag("a").getFirst();
var imageContainer =
linkElement.getElementsByClass("manga-card-image").getFirst();
var contentContainer =
linkElement.getElementsByClass("manga-card-content").getFirst();
var imageUrl =
imageContainer
.getElementsByClass("attachment-manga-cover")
.getFirst()
.attr("data-lazy-src");
var title = contentContainer.getElementsByTag("h3").text();
var url = linkElement.attr("href");
var status =
switch (imageContainer
.getElementsByClass("manga-status")
.text()
.toLowerCase()) {
case "em andamento" -> MangaStatus.ONGOING;
case "completo" -> MangaStatus.COMPLETED;
case "hiato" -> MangaStatus.HIATUS;
default -> MangaStatus.UNKNOWN;
};
return new ContentProviderMangaInfoResponseDTO(title, url, imageUrl, status);
} catch (Exception e) {
return null;
}
})
.filter(Objects::nonNull)
.toList();
} catch (IOException | NoSuchElementException e) {
log.error("Error fetching mangas from MangaLivre", e);
return List.of();
}
}
private Integer getTotalPages() {
try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(url);
var navLinks = document.getElementsByClass("nav-links").getFirst();
var links = navLinks.getElementsByTag("a");
var pageNumbers =
links.stream()
.map(Element::text)
.filter(NUMERIC_PATTERN.asMatchPredicate())
.map(Integer::parseInt)
.toList();
return pageNumbers.stream().max(Integer::compareTo).orElse(null);
} catch (IOException | NoSuchElementException e) {
log.error("Error fetching total pages from MangaLivre", e);
return null;
}
}
}

View File

@ -0,0 +1,152 @@
package com.magamochi.mangamochi.service.providers.impl;
import com.magamochi.mangamochi.model.dto.ContentProviderMangaChapterResponseDTO;
import com.magamochi.mangamochi.model.dto.ContentProviderMangaInfoResponseDTO;
import com.magamochi.mangamochi.model.entity.MangaProvider;
import com.magamochi.mangamochi.model.enumeration.MangaStatus;
import com.magamochi.mangamochi.service.WebScrapperClientProxyService;
import com.magamochi.mangamochi.service.providers.ContentProvider;
import com.magamochi.mangamochi.service.providers.ContentProviders;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
@Log4j2
@Service(ContentProviders.MANGA_LIVRE)
@RequiredArgsConstructor
public class MangaLivreProvider implements ContentProvider {
private final String url = "https://mangalivre.tv/manga/";
private final WebScrapperClientProxyService webScrapperClientProxyService;
@Override
public List<ContentProviderMangaInfoResponseDTO> getAvailableMangas() {
var totalPages = getTotalPages();
if (Objects.isNull(totalPages) || totalPages < 1) {
return List.of();
}
return IntStream.rangeClosed(1, totalPages)
.mapToObj(this::getMangasFromPage)
.filter(Objects::nonNull)
.flatMap(List::stream)
.toList();
}
@Override
public List<ContentProviderMangaChapterResponseDTO> getAvailableChapters(MangaProvider provider) {
try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(provider.getUrl());
var chapterItems = document.getElementsByClass("wp-manga-chapter");
return chapterItems.stream()
.map(
chapterItemElement -> {
var linkElement = chapterItemElement.getElementsByTag("a").getFirst();
return new ContentProviderMangaChapterResponseDTO(
linkElement.text(), linkElement.attr("href"));
})
.toList();
} catch (NoSuchElementException | IOException e) {
log.error("Error parsing mangas from MangaLivre", e);
return List.of();
}
}
@Override
public Map<Integer, String> getChapterImagesUrls(String chapterUrl) {
try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(chapterUrl);
var chapterImagesContainer = document.getElementsByClass("chapter-images").getFirst();
var chapterImagesElements = chapterImagesContainer.getElementsByClass("page-break");
var imageUrls =
chapterImagesElements.stream()
.map(
chapterImagesElement -> {
var imageElement = chapterImagesElement.getElementsByTag("img").getFirst();
return imageElement.attr("src");
})
.toList();
return IntStream.range(0, imageUrls.size())
.boxed()
.collect(
Collectors.toMap(
i -> i, imageUrls::get, (existing, replacement) -> existing, LinkedHashMap::new));
} catch (NoSuchElementException | IOException e) {
log.error("Error parsing mangas from MangaLivre", e);
return Map.of();
}
}
private List<ContentProviderMangaInfoResponseDTO> getMangasFromPage(int page) {
try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(url + "page/" + page);
var mangaElements = document.getElementsByClass("manga__item");
return mangaElements.stream()
.map(
element -> {
var mangaTitleElement =
element
.getElementsByClass("manga__content")
.getFirst()
.getElementsByClass("manga__content_item")
.getFirst()
.getElementsByClass("post-title font-title")
.getFirst()
.getElementsByTag("h2")
.getFirst();
var linkElement = mangaTitleElement.getElementsByTag("a").getFirst();
var url = linkElement.attr("href");
var title = linkElement.text().trim();
var imageElement =
element
.getElementsByClass("manga__thumb")
.getFirst()
.getElementsByClass("manga__thumb_item")
.getFirst()
.getElementsByTag("a")
.getFirst()
.getElementsByTag("img")
.getFirst();
var imgUrl = imageElement.attr("src");
return new ContentProviderMangaInfoResponseDTO(
title, url, imgUrl, MangaStatus.UNKNOWN);
})
.toList();
} catch (NoSuchElementException | IOException e) {
log.error("Error parsing mangas from MangaLivre", e);
return List.of();
}
}
private Integer getTotalPages() {
try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(url);
var navLinks = document.getElementsByClass("wp-pagenavi").getFirst();
var lastPageElement = navLinks.getElementsByClass("last").getFirst();
var links = lastPageElement.attr("href");
var totalPages = links.replaceAll("\\D+", "");
return Integer.parseInt(totalPages);
} catch (NoSuchElementException | IOException e) {
log.error("Error parsing total pages from MangaLivre", e);
return null;
}
}
}

View File

@ -0,0 +1,142 @@
package com.magamochi.mangamochi.task;
import static java.util.Objects.isNull;
import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.mangamochi.client.JikanClient;
import com.magamochi.mangamochi.model.entity.Author;
import com.magamochi.mangamochi.model.entity.Genre;
import com.magamochi.mangamochi.model.entity.MangaAuthor;
import com.magamochi.mangamochi.model.entity.MangaGenre;
import com.magamochi.mangamochi.model.repository.*;
import com.magamochi.mangamochi.service.ImageService;
import java.io.BufferedInputStream;
import java.net.URI;
import java.net.URL;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Component;
@Log4j2
@Component
@RequiredArgsConstructor
public class UpdateMangaDataTask {
private final AuthorRepository authorRepository;
private final MangaAuthorRepository mangaAuthorRepository;
private final MangaRepository mangaRepository;
private final JikanClient jikanClient;
private final ImageService imageService;
private final GenreRepository genreRepository;
private final MangaGenreRepository mangaGenreRepository;
// @Scheduled(fixedDelayString = "1d")
public void updateMangaData() {
var rateLimiter = RateLimiter.create(1);
var mangas =
mangaRepository.findAll().stream().filter(manga -> isNull(manga.getCoverImage())).toList();
mangas.forEach(
manga -> {
log.info("Updating manga {}", manga.getTitle());
try {
rateLimiter.acquire();
var mangaData = jikanClient.getMangaById(manga.getMalId());
manga.setAlternativeTitles(mangaData.data().title_synonyms());
manga.setSynopsis(mangaData.data().synopsis());
manga.setStatus(mangaData.data().status());
// manga.setScore(mangaData.data().score());
manga.setPublishedFrom(mangaData.data().published().from());
manga.setPublishedTo(mangaData.data().published().to());
var authors =
mangaData.data().authors().stream()
.map(
authorData -> {
return authorRepository
.findByMalId(authorData.mal_id())
.orElseGet(
() ->
authorRepository.save(
Author.builder()
.malId(authorData.mal_id())
.name(authorData.name())
.build()));
})
.toList();
var mangaAuthors =
authors.stream()
.map(
author -> {
return mangaAuthorRepository
.findByMangaAndAuthor(manga, author)
.orElseGet(
() ->
mangaAuthorRepository.save(
MangaAuthor.builder()
.manga(manga)
.author(author)
.build()));
})
.toList();
manga.setMangaAuthors(mangaAuthors);
var genres =
mangaData.data().genres().stream()
.map(
genreData -> {
return genreRepository
.findByMalId(genreData.mal_id())
.orElseGet(
() ->
genreRepository.save(
Genre.builder()
.malId(genreData.mal_id())
.name(genreData.name())
.build()));
})
.toList();
var mangaGenres =
genres.stream()
.map(
genre -> {
return mangaGenreRepository
.findByMangaAndGenre(manga, genre)
.orElseGet(
() ->
mangaGenreRepository.save(
MangaGenre.builder().manga(manga).genre(genre).build()));
})
.toList();
manga.setMangaGenres(mangaGenres);
var inputStream =
new BufferedInputStream(
new URL(
new URI(mangaData.data().images().jpg().large_image_url())
.toASCIIString())
.openStream());
var bytes = inputStream.readAllBytes();
inputStream.close();
var image = imageService.uploadImage(bytes, "image/jpeg", "cover");
manga.setCoverImage(image);
mangaRepository.save(manga);
} catch (Exception e) {
log.warn("Error updating manga data for manga {}. {}", manga.getTitle(), e);
}
});
}
}

View File

@ -0,0 +1,33 @@
package com.magamochi.mangamochi.task;
import com.magamochi.mangamochi.service.MangaListService;
import com.magamochi.mangamochi.service.providers.ContentProvider;
import com.magamochi.mangamochi.service.providers.ContentProviderFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Component;
@Log4j2
@Component
@RequiredArgsConstructor
public class UpdateMangaListTask {
private final ContentProviderFactory contentProviderFactory;
private final MangaListService mangaListService;
// @Scheduled(fixedDelayString = "1d")
public void updateMangaList() {
log.info("Updating manga list...");
var contentProviders = contentProviderFactory.getContentProviders();
contentProviders.forEach(this::updateProviderMangaList);
log.info("Manga list updated.");
}
private void updateProviderMangaList(
String contentProviderName, ContentProvider contentProvider) {
log.info("Updating manga list for content provider {}", contentProviderName);
mangaListService.updateMangaList(contentProviderName, contentProvider.getAvailableMangas());
log.info("Manga list for content provider {} updated.", contentProviderName);
}
}

View File

@ -0,0 +1,68 @@
package com.magamochi.mangamochi.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
private Key getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes());
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser().setSigningKey(getSigningKey()).build().parseClaimsJws(token).getBody();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername());
}
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}

View File

@ -0,0 +1,3 @@
spring:
config:
import: optional:file:.env[.properties]

View File

@ -0,0 +1,34 @@
spring:
application:
name: mangamochi
datasource:
url: ${DB_URL}
username: ${DB_USER}
password: ${DB_PASS}
jpa:
properties:
hibernate:
default_schema: mangamochi
flyway:
enabled: true
schemas:
- mangamochi
default-schema: mangamochi
springdoc:
api-docs:
path: /api-docs
web-scrapper:
endpoint: ${WEBSCRAPPER_ENDPOINT}
minio:
endpoint: ${MINIO_ENDPOINT}
accessKey: ${MINIO_USER}
secretKey: ${MINIO_PASS}
bucket: mangamochi
# JWT Configuration
jwt:
secret: mySecretKeymySecretKeymySecretKeymySecretKeymySecretKeymySecretKey
expiration: 86400000 # 24 hours in milliseconds

View File

@ -0,0 +1,7 @@
CREATE TABLE images
(
id UUID NOT NULL PRIMARY KEY,
file_key VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@ -0,0 +1,11 @@
CREATE TABLE mangas
(
id BIGSERIAL NOT NULL PRIMARY KEY,
mal_id BIGINT UNIQUE,
title VARCHAR,
alternative_titles TEXT[],
status VARCHAR,
synopsis VARCHAR,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@ -0,0 +1,21 @@
CREATE TABLE providers
(
id BIGSERIAL NOT NULL PRIMARY KEY,
name VARCHAR NOT NULL,
status VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE manga_provider
(
id BIGSERIAL NOT NULL PRIMARY KEY,
manga_id BIGINT NOT NULL REFERENCES mangas (id) ON DELETE CASCADE,
provider_id BIGINT NOT NULL REFERENCES providers (id) ON DELETE CASCADE,
manga_title VARCHAR NOT NULL,
url VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (manga_id, provider_id)
)

View File

@ -0,0 +1,20 @@
CREATE TABLE manga_chapters
(
id BIGSERIAL NOT NULL PRIMARY KEY,
manga_provider_id BIGINT NOT NULL REFERENCES manga_provider (id) ON DELETE CASCADE,
title VARCHAR NOT NULL,
url VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE manga_chapter_images
(
id BIGSERIAL NOT NULL PRIMARY KEY,
manga_chapter_id BIGINT NOT NULL REFERENCES manga_chapters (id) ON DELETE CASCADE,
image_id UUID REFERENCES images (id) ON DELETE CASCADE,
position INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)

View File

@ -0,0 +1,8 @@
CREATE TABLE manga_import_reviews
(
id BIGSERIAL NOT NULL PRIMARY KEY,
provider_id BIGINT NOT NULL REFERENCES providers (id) ON DELETE CASCADE,
title VARCHAR NOT NULL,
url VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@ -0,0 +1,5 @@
ALTER TABLE mangas
ADD COLUMN cover_image_id UUID REFERENCES images (id),
ADD COLUMN score DOUBLE PRECISION,
ADD COLUMN published_from TIMESTAMPTZ,
ADD COLUMN published_to TIMESTAMPTZ;

View File

@ -0,0 +1,31 @@
CREATE TABLE authors
(
id BIGSERIAL NOT NULL PRIMARY KEY,
mal_id BIGINT UNIQUE,
name VARCHAR,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE manga_author
(
id BIGSERIAL NOT NULL PRIMARY KEY,
manga_id BIGINT REFERENCES mangas (id),
author_id BIGINT REFERENCES authors (id),
UNIQUE (manga_id, author_id)
);
CREATE TABLE genres
(
id BIGSERIAL NOT NULL PRIMARY KEY,
mal_id BIGINT UNIQUE,
name VARCHAR
);
CREATE TABLE manga_genre
(
id BIGSERIAL NOT NULL PRIMARY KEY,
manga_id BIGINT REFERENCES mangas (id),
genre_id BIGINT REFERENCES genres (id),
UNIQUE (manga_id, genre_id)
);

View File

@ -0,0 +1 @@
ALTER TABLE manga_chapters ADD COLUMN downloaded BOOLEAN DEFAULT FALSE;

View File

@ -0,0 +1 @@
ALTER TABLE manga_chapters ADD COLUMN read BOOLEAN DEFAULT FALSE;

View File

@ -0,0 +1,6 @@
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR NOT NULL UNIQUE,
password VARCHAR NOT NULL,
role VARCHAR
);

View File

@ -0,0 +1,22 @@
package com.magamochi.mangamochi;
import com.magamochi.mangamochi.client.JikanClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.openfeign.EnableFeignClients;
@Log4j2
@SpringBootTest
@RequiredArgsConstructor
@EnableFeignClients
class MangamochiApplicationTests {
private final JikanClient jikanClient;
@Test
void testJikan() {
var response = jikanClient.mangaSearch("Saint Seiya");
log.info(response.toString());
}
}