diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..4db9601d
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,24 @@
+version: 2
+enable-beta-ecosystems: true
+updates:
+ - package-ecosystem: "gradle"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "10:00"
+ timezone: "Europe/Paris"
+ assignees:
+ - "Ziedelth"
+ reviewers:
+ - "Ziedelth"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "10:00"
+ timezone: "Europe/Paris"
+ assignees:
+ - "Ziedelth"
+ reviewers:
+ - "Ziedelth"
\ No newline at end of file
diff --git a/.github/workflows/update-gradle-wrapper.yml b/.github/workflows/update-gradle-wrapper.yml
new file mode 100644
index 00000000..43e6d4e6
--- /dev/null
+++ b/.github/workflows/update-gradle-wrapper.yml
@@ -0,0 +1,21 @@
+name: Update Gradle Wrapper
+
+on:
+ schedule:
+ - cron: "30 11 * * *"
+
+jobs:
+ update-gradle-wrapper:
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: write
+ pull-requests: write
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Update Gradle Wrapper
+ uses: gradle-update/update-gradle-wrapper-action@v1
+ with:
+ reviewers: Ziedelth
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..e87de456
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,38 @@
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+/metrics.json
+/.jpb/
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 00000000..b98fd8c8
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,47 @@
+val ktor_version: String by project
+val kotlin_version: String by project
+
+plugins {
+ kotlin("jvm") version "1.9.21"
+ id("io.ktor.plugin") version "2.3.6"
+}
+
+group = "fr.shikkanime"
+version = "0.0.1"
+
+application {
+ mainClass.set("fr.shikkanime.ApplicationKt")
+
+ val isDevelopment: Boolean = project.ext.has("development")
+ applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
+ implementation("io.ktor:ktor-server-auth-jvm:$ktor_version")
+ implementation("io.ktor:ktor-server-auth-jwt-jvm:$ktor_version")
+ implementation("io.ktor:ktor-server-host-common-jvm:$ktor_version")
+ implementation("io.ktor:ktor-server-status-pages-jvm:$ktor_version")
+ implementation("io.ktor:ktor-server-caching-headers-jvm:$ktor_version")
+ implementation("io.ktor:ktor-server-compression-jvm:$ktor_version")
+ implementation("io.ktor:ktor-server-cors-jvm:$ktor_version")
+ implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktor_version")
+ implementation("io.ktor:ktor-serialization-gson:$ktor_version")
+ implementation("io.ktor:ktor-server-freemarker-jvm:$ktor_version")
+ implementation("io.ktor:ktor-server-netty-jvm:$ktor_version")
+ implementation("io.ktor:ktor-client-core:$ktor_version")
+ implementation("io.ktor:ktor-client-cio:$ktor_version")
+ implementation("org.hibernate.orm:hibernate-core:6.4.0.Final")
+ implementation("org.postgresql:postgresql:42.7.0")
+ implementation("org.reflections:reflections:0.10.2")
+ implementation("com.google.inject:guice:7.0.0")
+ implementation("org.liquibase:liquibase-core:4.25.0")
+ implementation("org.quartz-scheduler:quartz:2.5.0-rc1")
+ implementation("com.google.guava:guava:32.1.3-jre")
+ testImplementation("io.ktor:ktor-server-tests-jvm:$ktor_version")
+ testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 00000000..2b55b077
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,3 @@
+ktor_version=2.3.6
+kotlin_version=1.9.21
+kotlin.code.style=official
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..7454180f
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..e411586a
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 00000000..1b6c7873
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,234 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed 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
+#
+# https://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.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+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
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 00000000..107acd32
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/hibernate.cfg.xml b/hibernate.cfg.xml
new file mode 100644
index 00000000..425cd7fa
--- /dev/null
+++ b/hibernate.cfg.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+ jdbc:postgresql://localhost:5432/shikkanime
+ postgres
+ mysecretpassword
+ false
+ org.hibernate.context.internal.ThreadLocalSessionContext
+
+
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 00000000..c70332dd
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1 @@
+rootProject.name = "core"
diff --git a/src/main/kotlin/fr/shikkanime/Application.kt b/src/main/kotlin/fr/shikkanime/Application.kt
new file mode 100644
index 00000000..bc202a16
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/Application.kt
@@ -0,0 +1,32 @@
+package fr.shikkanime
+
+import fr.shikkanime.jobs.FetchEpisodesJob
+import fr.shikkanime.jobs.GCJob
+import fr.shikkanime.jobs.MetricJob
+import fr.shikkanime.plugins.configureHTTP
+import fr.shikkanime.plugins.configureRouting
+import fr.shikkanime.plugins.configureSecurity
+import fr.shikkanime.utils.JobManager
+import io.ktor.server.application.*
+import io.ktor.server.engine.*
+import io.ktor.server.netty.*
+
+fun main() {
+ JobManager.scheduleJob("*/10 * * * * ?", MetricJob::class.java)
+ JobManager.scheduleJob("0 */5 * * * ?", GCJob::class.java)
+ JobManager.scheduleJob("0 */2 * * * ?", FetchEpisodesJob::class.java)
+ JobManager.start()
+
+ embeddedServer(
+ Netty,
+ port = 37100,
+ host = "0.0.0.0",
+ module = Application::module
+ ).start(wait = true)
+}
+
+fun Application.module() {
+ configureSecurity()
+ configureHTTP()
+ configureRouting()
+}
diff --git a/src/main/kotlin/fr/shikkanime/caches/FromToZonedDateTimeKeyCache.kt b/src/main/kotlin/fr/shikkanime/caches/FromToZonedDateTimeKeyCache.kt
new file mode 100644
index 00000000..98bcb09a
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/caches/FromToZonedDateTimeKeyCache.kt
@@ -0,0 +1,28 @@
+package fr.shikkanime.caches
+
+import java.time.ZonedDateTime
+
+data class FromToZonedDateTimeKeyCache(
+ val from: ZonedDateTime,
+ val to: ZonedDateTime
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is FromToZonedDateTimeKeyCache) return false
+
+ if (from != other.from) return false
+ if (to != other.to) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = from.hashCode()
+ result = 31 * result + to.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "FromToZonedDateTimeKeyCache(from=$from, to=$to)"
+ }
+}
diff --git a/src/main/kotlin/fr/shikkanime/controllers/AdminController.kt b/src/main/kotlin/fr/shikkanime/controllers/AdminController.kt
new file mode 100644
index 00000000..9d48a3b0
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/controllers/AdminController.kt
@@ -0,0 +1,28 @@
+package fr.shikkanime.controllers
+
+import fr.shikkanime.utils.routes.Controller
+import fr.shikkanime.utils.routes.Path
+import fr.shikkanime.utils.routes.Response
+import fr.shikkanime.utils.routes.TemplateResponse
+import fr.ziedelth.utils.routes.method.Get
+
+@Controller("/admin")
+class AdminController {
+ @Path("/dashboard")
+ @Get
+ private fun getDashboard(): Response {
+ return TemplateResponse(
+ "admin/dashboard.ftl",
+ "Dashboard",
+ )
+ }
+
+ @Path("/platforms")
+ @Get
+ private fun getPlatforms(): Response {
+ return TemplateResponse(
+ "admin/platforms.ftl",
+ "Platforms",
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt b/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt
new file mode 100644
index 00000000..8582a870
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt
@@ -0,0 +1,24 @@
+package fr.shikkanime.controllers.api
+
+import com.google.inject.Inject
+import fr.shikkanime.converters.AbstractConverter
+import fr.shikkanime.dtos.MetricDto
+import fr.shikkanime.services.MetricService
+import fr.shikkanime.utils.routes.Controller
+import fr.shikkanime.utils.routes.Path
+import fr.shikkanime.utils.routes.Response
+import fr.ziedelth.utils.routes.method.Get
+import java.time.ZonedDateTime
+
+@Controller("/api/metrics")
+class MetricController {
+ @Inject
+ private lateinit var metricService: MetricService
+
+ @Path
+ @Get
+ private fun getPlatforms(): Response {
+ val oneHourAgo = ZonedDateTime.now().minusHours(1)
+ return Response.ok(AbstractConverter.convert(metricService.findAllAfter(oneHourAgo), MetricDto::class.java))
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/controllers/api/PlatformController.kt b/src/main/kotlin/fr/shikkanime/controllers/api/PlatformController.kt
new file mode 100644
index 00000000..96eb9c60
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/controllers/api/PlatformController.kt
@@ -0,0 +1,43 @@
+package fr.shikkanime.controllers.api
+
+import com.google.inject.Inject
+import fr.shikkanime.converters.AbstractConverter
+import fr.shikkanime.dtos.PlatformDto
+import fr.shikkanime.entities.Platform
+import fr.shikkanime.services.PlatformService
+import fr.shikkanime.utils.routes.BodyParam
+import fr.shikkanime.utils.routes.Controller
+import fr.shikkanime.utils.routes.Path
+import fr.shikkanime.utils.routes.Response
+import fr.ziedelth.utils.routes.method.Get
+import fr.ziedelth.utils.routes.method.Post
+import java.util.*
+
+@Controller("/api/platforms")
+class PlatformController {
+ @Inject
+ private lateinit var platformService: PlatformService
+
+ @Path
+ @Get
+ private fun getPlatforms(): Response {
+ return Response.ok(AbstractConverter.convert(platformService.findAll(), PlatformDto::class.java))
+ }
+
+ @Path("/{uuid}")
+ @Get
+ private fun getPlatform(uuid: UUID): Response {
+ return Response.ok(AbstractConverter.convert(platformService.find(uuid), PlatformDto::class.java))
+ }
+
+ @Path
+ @Post
+ private fun createPlatform(@BodyParam platformDto: PlatformDto): Response {
+ return Response.ok(
+ AbstractConverter.convert(
+ platformService.save(AbstractConverter.convert(platformDto, Platform::class.java)),
+ PlatformDto::class.java
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/converters/AbstractConverter.kt b/src/main/kotlin/fr/shikkanime/converters/AbstractConverter.kt
new file mode 100644
index 00000000..a6f1e211
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/converters/AbstractConverter.kt
@@ -0,0 +1,47 @@
+package fr.shikkanime.converters
+
+import fr.shikkanime.utils.Constant
+import java.lang.reflect.ParameterizedType
+
+abstract class AbstractConverter {
+ abstract fun convert(from: F): T
+
+ companion object {
+ private val converters: MutableMap, Class<*>>, AbstractConverter<*, *>> = mutableMapOf()
+
+ init {
+ val converters = Constant.reflections.getSubTypesOf(AbstractConverter::class.java)
+
+ converters.forEach {
+ val (from, to) = (it.genericSuperclass as ParameterizedType).actualTypeArguments.map { argument -> argument as Class<*> }
+ this.converters[Pair(from, to)] = Constant.guice.getInstance(it)
+ }
+ }
+
+ fun convert(`object`: Any?, to: Class): T {
+ if (`object` == null) {
+ throw NullPointerException("Can not convert null to \"${to.simpleName}\"")
+ }
+
+ val pair = Pair(`object`.javaClass, to)
+
+ if (!converters.containsKey(pair)) {
+ throw NoSuchElementException("Can not find converter \"${`object`.javaClass.simpleName}\" to \"${to.simpleName}\"")
+ }
+
+ val abstractConverter = converters[pair] ?: throw IllegalStateException()
+ val abstractConverterClass = abstractConverter.javaClass
+ val method = abstractConverterClass.getMethod("convert", `object`.javaClass)
+ method.isAccessible = true
+ return try {
+ method.invoke(abstractConverter, `object`) as T
+ } catch (e: Exception) {
+ throw IllegalStateException("Can not convert \"${`object`.javaClass.simpleName}\" to \"${to.simpleName}\"", e)
+ }
+ }
+
+ fun convert(list: List, to: Class): List {
+ return list.map { convert(it, to) }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/converters/metric/MetricToMetricDtoConverter.kt b/src/main/kotlin/fr/shikkanime/converters/metric/MetricToMetricDtoConverter.kt
new file mode 100644
index 00000000..69a8e9db
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/converters/metric/MetricToMetricDtoConverter.kt
@@ -0,0 +1,33 @@
+package fr.shikkanime.converters.metric
+
+import com.google.inject.Inject
+import fr.shikkanime.converters.AbstractConverter
+import fr.shikkanime.dtos.MetricDto
+import fr.shikkanime.entities.Metric
+import fr.shikkanime.services.MetricService
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+class MetricToMetricDtoConverter : AbstractConverter() {
+ private val europeParisZone = ZoneId.of("UTC")
+ private val dateFormatter = DateTimeFormatter.ofPattern("HH:mm:ssZ")
+
+ @Inject
+ private lateinit var metricService: MetricService
+
+ private fun Double.toDoublePoint() = String.format("%.2f", this)
+
+ override fun convert(from: Metric): MetricDto {
+ val minusHours = from.date.minusHours(1)
+
+ return MetricDto(
+ uuid = from.uuid,
+ cpuLoad = (from.cpuLoad * 100).toString().replace(',', '.'),
+ averageCpuLoad = metricService.getAverageCpuLoad(minusHours, from.date)?.times(100)?.toString()?.replace(',', '.') ?: "0",
+ memoryUsage = (from.memoryUsage / 1024.0 / 1024.0).toString().replace(',', '.'),
+ averageMemoryUsage = metricService.getAverageMemoryUsage(minusHours, from.date)?.div(1024)?.div(1024)?.toString()?.replace(',', '.') ?: "0",
+ databaseSize = (from.databaseSize / 1024.0 / 1024.0).toDoublePoint(),
+ date = from.date.withZoneSameInstant(europeParisZone).format(dateFormatter)
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/converters/platform/PlatformDtoToPlatformConverter.kt b/src/main/kotlin/fr/shikkanime/converters/platform/PlatformDtoToPlatformConverter.kt
new file mode 100644
index 00000000..58125a09
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/converters/platform/PlatformDtoToPlatformConverter.kt
@@ -0,0 +1,28 @@
+package fr.shikkanime.converters.platform
+
+import com.google.inject.Inject
+import fr.shikkanime.converters.AbstractConverter
+import fr.shikkanime.dtos.PlatformDto
+import fr.shikkanime.entities.Platform
+import fr.shikkanime.services.PlatformService
+
+class PlatformDtoToPlatformConverter : AbstractConverter() {
+ @Inject
+ private lateinit var platformService: PlatformService
+
+ override fun convert(from: PlatformDto): Platform {
+ if (from.uuid != null) {
+ val find = platformService.find(from.uuid)
+
+ if (find != null) {
+ return find
+ }
+ }
+
+ return Platform(
+ name = from.name,
+ url = from.url,
+ image = from.image
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/converters/platform/PlatformToPlatformDtoConverter.kt b/src/main/kotlin/fr/shikkanime/converters/platform/PlatformToPlatformDtoConverter.kt
new file mode 100644
index 00000000..8b962248
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/converters/platform/PlatformToPlatformDtoConverter.kt
@@ -0,0 +1,16 @@
+package fr.shikkanime.converters.platform
+
+import fr.shikkanime.converters.AbstractConverter
+import fr.shikkanime.dtos.PlatformDto
+import fr.shikkanime.entities.Platform
+
+class PlatformToPlatformDtoConverter : AbstractConverter() {
+ override fun convert(from: Platform): PlatformDto {
+ return PlatformDto(
+ uuid = from.uuid,
+ name = from.name!!,
+ url = from.url!!,
+ image = from.image!!
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/dtos/MetricDto.kt b/src/main/kotlin/fr/shikkanime/dtos/MetricDto.kt
new file mode 100644
index 00000000..eee54f99
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/dtos/MetricDto.kt
@@ -0,0 +1,14 @@
+package fr.shikkanime.dtos
+
+import java.io.Serializable
+import java.util.*
+
+data class MetricDto(
+ val uuid: UUID?,
+ val cpuLoad: String,
+ val averageCpuLoad: String,
+ val memoryUsage: String,
+ val averageMemoryUsage: String,
+ val databaseSize: String,
+ val date: String,
+) : Serializable
diff --git a/src/main/kotlin/fr/shikkanime/dtos/PlatformDto.kt b/src/main/kotlin/fr/shikkanime/dtos/PlatformDto.kt
new file mode 100644
index 00000000..1825410e
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/dtos/PlatformDto.kt
@@ -0,0 +1,11 @@
+package fr.shikkanime.dtos
+
+import java.io.Serializable
+import java.util.*
+
+data class PlatformDto(
+ val uuid: UUID?,
+ val name: String,
+ val url: String,
+ val image: String
+) : Serializable
diff --git a/src/main/kotlin/fr/shikkanime/entities/Country.kt b/src/main/kotlin/fr/shikkanime/entities/Country.kt
new file mode 100644
index 00000000..9c090369
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/entities/Country.kt
@@ -0,0 +1,46 @@
+package fr.shikkanime.entities
+
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.Index
+import jakarta.persistence.Table
+import java.util.*
+
+@Entity
+@Table(
+ name = "country",
+ indexes = [
+ Index(name = "idx_country_country_code", columnList = "country_code")
+ ]
+)
+data class Country(
+ override val uuid: UUID? = null,
+ @Column(nullable = false, unique = true)
+ val name: String? = null,
+ @Column(nullable = false, unique = true, name = "country_code")
+ val countryCode: String? = null,
+) : ShikkEntity(uuid) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Country) return false
+ if (!super.equals(other)) return false
+
+ if (uuid != other.uuid) return false
+ if (name != other.name) return false
+ if (countryCode != other.countryCode) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = super.hashCode()
+ result = 31 * result + (uuid?.hashCode() ?: 0)
+ result = 31 * result + (name?.hashCode() ?: 0)
+ result = 31 * result + (countryCode?.hashCode() ?: 0)
+ return result
+ }
+
+ override fun toString(): String {
+ return "Country(uuid=$uuid, name=$name, countryCode=$countryCode)"
+ }
+}
diff --git a/src/main/kotlin/fr/shikkanime/entities/Link.kt b/src/main/kotlin/fr/shikkanime/entities/Link.kt
new file mode 100644
index 00000000..d1493e36
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/entities/Link.kt
@@ -0,0 +1,17 @@
+package fr.shikkanime.entities
+
+data class Link(
+ val href: String,
+ val icon: String,
+ val name: String,
+ var active: Boolean = false,
+) {
+ companion object {
+ fun list(): List {
+ return listOf(
+ Link("/admin/dashboard", "bi bi-speedometer2", "Dashboard"),
+ Link("/admin/platforms", "bi bi-display", "Platforms"),
+ )
+ }
+ }
+}
diff --git a/src/main/kotlin/fr/shikkanime/entities/Metric.kt b/src/main/kotlin/fr/shikkanime/entities/Metric.kt
new file mode 100644
index 00000000..9b19ef20
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/entities/Metric.kt
@@ -0,0 +1,53 @@
+package fr.shikkanime.entities
+
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.Index
+import jakarta.persistence.Table
+import java.time.ZonedDateTime
+import java.util.*
+
+@Entity
+@Table(
+ name = "metric",
+ indexes = [
+ Index(name = "idx_metrics_date", columnList = "date")
+ ]
+)
+data class Metric(
+ override val uuid: UUID? = null,
+ @Column(name = "cpu_load")
+ val cpuLoad: Double = 0.0,
+ @Column(name = "memory_usage")
+ val memoryUsage: Long = 0,
+ @Column(name = "database_size")
+ val databaseSize: Long = 0,
+ @Column(name = "date")
+ val date: ZonedDateTime = ZonedDateTime.now(),
+) : ShikkEntity(uuid) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Metric) return false
+
+ if (uuid != other.uuid) return false
+ if (cpuLoad != other.cpuLoad) return false
+ if (memoryUsage != other.memoryUsage) return false
+ if (databaseSize != other.databaseSize) return false
+ if (date != other.date) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = uuid?.hashCode() ?: 0
+ result = 31 * result + cpuLoad.hashCode()
+ result = 31 * result + memoryUsage.hashCode()
+ result = 31 * result + databaseSize.hashCode()
+ result = 31 * result + date.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "Metric(uuid=$uuid, cpuLoad=$cpuLoad, memoryUsage=$memoryUsage, databaseSize=$databaseSize, date=$date)"
+ }
+}
diff --git a/src/main/kotlin/fr/shikkanime/entities/Platform.kt b/src/main/kotlin/fr/shikkanime/entities/Platform.kt
new file mode 100644
index 00000000..220539f8
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/entities/Platform.kt
@@ -0,0 +1,44 @@
+package fr.shikkanime.entities
+
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.Table
+import java.util.*
+
+@Entity
+@Table(name = "platform")
+data class Platform(
+ override val uuid: UUID? = null,
+ @Column(nullable = false, unique = true)
+ val name: String? = null,
+ @Column(nullable = false)
+ val url: String? = null,
+ @Column(nullable = false)
+ val image: String? = null
+) : ShikkEntity(uuid) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Platform
+
+ if (uuid != other.uuid) return false
+ if (name != other.name) return false
+ if (url != other.url) return false
+ if (image != other.image) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = uuid?.hashCode() ?: 0
+ result = 31 * result + (name?.hashCode() ?: 0)
+ result = 31 * result + (url?.hashCode() ?: 0)
+ result = 31 * result + (image?.hashCode() ?: 0)
+ return result
+ }
+
+ override fun toString(): String {
+ return "Platform(uuid=$uuid, name=$name, url=$url, image=$image)"
+ }
+}
diff --git a/src/main/kotlin/fr/shikkanime/entities/ShikkEntity.kt b/src/main/kotlin/fr/shikkanime/entities/ShikkEntity.kt
new file mode 100644
index 00000000..802de3cf
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/entities/ShikkEntity.kt
@@ -0,0 +1,31 @@
+package fr.shikkanime.entities
+
+import jakarta.persistence.Id
+import jakarta.persistence.MappedSuperclass
+import org.hibernate.annotations.UuidGenerator
+import java.io.Serializable
+import java.util.*
+
+@MappedSuperclass
+open class ShikkEntity(
+ @Id
+ @UuidGenerator
+ open val uuid: UUID? = null,
+) : Serializable {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is ShikkEntity) return false
+
+ if (uuid != other.uuid) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ return uuid?.hashCode() ?: 0
+ }
+
+ override fun toString(): String {
+ return "ShikkEntity(uuid=$uuid)"
+ }
+}
diff --git a/src/main/kotlin/fr/shikkanime/jobs/AbstractJob.kt b/src/main/kotlin/fr/shikkanime/jobs/AbstractJob.kt
new file mode 100644
index 00000000..d12fd588
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/jobs/AbstractJob.kt
@@ -0,0 +1,5 @@
+package fr.shikkanime.jobs
+
+abstract class AbstractJob {
+ abstract fun run()
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/jobs/FetchEpisodesJob.kt b/src/main/kotlin/fr/shikkanime/jobs/FetchEpisodesJob.kt
new file mode 100644
index 00000000..dfe3e89e
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/jobs/FetchEpisodesJob.kt
@@ -0,0 +1,24 @@
+package fr.shikkanime.jobs
+
+import fr.shikkanime.platforms.AbstractPlatform
+import fr.shikkanime.platforms.AnimationDigitalNetworkPlatform
+import fr.shikkanime.utils.Constant
+import kotlinx.coroutines.runBlocking
+import java.time.ZonedDateTime
+
+class FetchEpisodesJob : AbstractJob() {
+ private val platformClasses: List> = listOf(AnimationDigitalNetworkPlatform::class.java)
+
+ override fun run() {
+ val zonedDateTime = ZonedDateTime.now().withNano(0)
+
+ platformClasses.forEach { platformClass ->
+ val abstractPlatform = Constant.guice.getInstance(platformClass)
+ println("Fetching episodes for ${abstractPlatform.getPlatform().name}...")
+
+ runBlocking {
+ abstractPlatform.fetchEpisodes(zonedDateTime)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/jobs/GCJob.kt b/src/main/kotlin/fr/shikkanime/jobs/GCJob.kt
new file mode 100644
index 00000000..a79a5f3f
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/jobs/GCJob.kt
@@ -0,0 +1,8 @@
+package fr.shikkanime.jobs
+
+class GCJob : AbstractJob() {
+
+ override fun run() {
+ System.gc()
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/jobs/MetricJob.kt b/src/main/kotlin/fr/shikkanime/jobs/MetricJob.kt
new file mode 100644
index 00000000..a892ff91
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/jobs/MetricJob.kt
@@ -0,0 +1,41 @@
+package fr.shikkanime.jobs
+
+import com.google.inject.Inject
+import fr.shikkanime.entities.Metric
+import fr.shikkanime.services.MetricService
+import fr.shikkanime.utils.Database
+import java.lang.management.ManagementFactory
+import javax.management.Attribute
+import javax.management.ObjectName
+
+class MetricJob : AbstractJob() {
+ @Inject
+ private lateinit var metricService: MetricService
+
+ @Inject
+ private lateinit var database: Database
+
+ override fun run() {
+ metricService.save(
+ Metric(
+ cpuLoad = getProcessCPULoad(),
+ memoryUsage = getProcessMemoryUsage(),
+ databaseSize = database.getSize()
+ )
+ )
+ }
+
+ private fun getProcessCPULoad(): Double {
+ val mbs = ManagementFactory.getPlatformMBeanServer()
+ val name = ObjectName.getInstance("java.lang:type=OperatingSystem")
+ val list = mbs.getAttributes(name, arrayOf("ProcessCpuLoad"))
+ if (list.isEmpty()) return Double.NaN
+ val value = (list.first() as Attribute).value as Double? ?: return Double.NaN
+ if (value < 0) return Double.NaN
+ return value
+ }
+
+ private fun getProcessMemoryUsage(): Long {
+ return Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/modules/DefaultModule.kt b/src/main/kotlin/fr/shikkanime/modules/DefaultModule.kt
new file mode 100644
index 00000000..7de3b090
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/modules/DefaultModule.kt
@@ -0,0 +1,31 @@
+package fr.shikkanime.modules
+
+import com.google.inject.AbstractModule
+import fr.shikkanime.platforms.AbstractPlatform
+import fr.shikkanime.repositories.AbstractRepository
+import fr.shikkanime.services.AbstractService
+import fr.shikkanime.utils.Constant
+import fr.shikkanime.utils.Database
+import fr.shikkanime.utils.routes.Controller
+
+class DefaultModule : AbstractModule() {
+ override fun configure() {
+ bind(Database::class.java).asEagerSingleton()
+
+ Constant.reflections.getSubTypesOf(AbstractRepository::class.java).forEach {
+ bind(it).asEagerSingleton()
+ }
+
+ Constant.reflections.getSubTypesOf(AbstractService::class.java).forEach {
+ bind(it).asEagerSingleton()
+ }
+
+ Constant.reflections.getSubTypesOf(AbstractPlatform::class.java).forEach {
+ bind(it).asEagerSingleton()
+ }
+
+ Constant.reflections.getTypesAnnotatedWith(Controller::class.java).forEach {
+ bind(it).asEagerSingleton()
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/platforms/AbstractPlatform.kt b/src/main/kotlin/fr/shikkanime/platforms/AbstractPlatform.kt
new file mode 100644
index 00000000..4bc58dba
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/platforms/AbstractPlatform.kt
@@ -0,0 +1,43 @@
+package fr.shikkanime.platforms
+
+import fr.shikkanime.entities.Country
+import fr.shikkanime.entities.Platform
+import fr.shikkanime.services.CountryService
+import fr.shikkanime.services.PlatformService
+import jakarta.inject.Inject
+import java.time.ZonedDateTime
+
+abstract class AbstractPlatform {
+ data class Api(
+ val lastCheck: ZonedDateTime,
+ val content: Map = emptyMap(),
+ )
+
+ @Inject
+ protected lateinit var platformService: PlatformService
+
+ @Inject
+ protected lateinit var countryService: CountryService
+
+ private var apiCache: Api? = null
+
+ abstract fun getPlatform(): Platform
+ protected abstract fun getCountries(): List
+ abstract suspend fun fetchApiContent(zonedDateTime: ZonedDateTime): Api
+ abstract suspend fun fetchEpisodes(zonedDateTime: ZonedDateTime): List
+ abstract fun reset()
+
+ suspend fun getApiContent(country: Country, zonedDateTime: ZonedDateTime, delayHours: Long? = null): String {
+ if (apiCache == null) {
+ apiCache = fetchApiContent(zonedDateTime)
+ }
+
+ val plusHours = apiCache!!.lastCheck.plusHours(delayHours ?: 0)
+
+ if (zonedDateTime.isEqual(plusHours) || zonedDateTime.isAfter(plusHours)) {
+ apiCache = fetchApiContent(zonedDateTime)
+ }
+
+ return apiCache!!.content[country]!!
+ }
+}
diff --git a/src/main/kotlin/fr/shikkanime/platforms/AnimationDigitalNetworkPlatform.kt b/src/main/kotlin/fr/shikkanime/platforms/AnimationDigitalNetworkPlatform.kt
new file mode 100644
index 00000000..b8850fc0
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/platforms/AnimationDigitalNetworkPlatform.kt
@@ -0,0 +1,93 @@
+package fr.shikkanime.platforms
+
+import com.google.gson.Gson
+import com.google.gson.JsonObject
+import fr.shikkanime.entities.Country
+import fr.shikkanime.entities.Platform
+import fr.shikkanime.utils.HttpRequest
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import java.time.ZonedDateTime
+
+class AnimationDigitalNetworkPlatform : AbstractPlatform() {
+ override fun getPlatform(): Platform {
+ val name = "Animation Digital Network"
+
+ return platformService.findByName(name) ?: Platform(
+ name = name,
+ url = "https://animationdigitalnetwork.fr/",
+ image = "animation_digital_network.png",
+ )
+ }
+
+ override fun getCountries() = countryService.findAllByCode(listOf("FR"))
+
+ override suspend fun fetchApiContent(zonedDateTime: ZonedDateTime): Api {
+ val map = mutableMapOf()
+ val countries = getCountries()
+ val toDateString = zonedDateTime.toLocalDate().toString()
+
+ countries.forEach { country ->
+ val url = "https://gw.api.animationdigitalnetwork.${country.countryCode!!.lowercase()}/video/calendar?date=$toDateString"
+ val response = HttpRequest().get(url)
+
+ if (response.status != HttpStatusCode.OK) {
+ return@forEach
+ }
+
+ map[country] = response.bodyAsText()
+ }
+
+ return Api(zonedDateTime, map)
+ }
+
+ override suspend fun fetchEpisodes(zonedDateTime: ZonedDateTime): List {
+ val countries = getCountries()
+
+ countries.forEach { country ->
+ val api = getApiContent(country, zonedDateTime, 1)
+ val array = Gson().fromJson(api, JsonObject::class.java).getAsJsonArray("videos")
+
+ array.forEach {
+ convertEpisode(it.asJsonObject, zonedDateTime)
+ }
+ }
+
+ return emptyList()
+ }
+
+ override fun reset() {
+ TODO("Not yet implemented")
+ }
+
+ private fun convertEpisode(jsonObject: JsonObject, zonedDateTime: ZonedDateTime) {
+ val show = jsonObject.getAsJsonObject("show") ?: return
+
+ var animeName = show["shortTitle"]?.asString ?: show["title"]?.asString ?: return
+ animeName = animeName.replace(Regex("Saison \\d"), "").trim('-').trim()
+
+ val animeImage = show["image2x"]?.asString ?: return
+ val animeDescription = show["summary"]?.asString ?: ""
+ val genres = show.getAsJsonArray("genres")?.toList() ?: emptyList()
+
+ if (genres.isEmpty() || !genres.any { it.asString.startsWith("Animation ", true) }) {
+ return
+ }
+
+ var isSimulcasted = show["simulcast"]?.asBoolean == true ||
+ show["firstReleaseYear"]?.asString == zonedDateTime.toLocalDate().year.toString()
+
+ val descriptionLowercase = animeDescription.lowercase()
+
+ isSimulcasted = isSimulcasted || descriptionLowercase.startsWith("(premier épisode ") ||
+ descriptionLowercase.startsWith("(diffusion des ") ||
+ descriptionLowercase.startsWith("(diffusion du premier épisode") ||
+ descriptionLowercase.startsWith("(diffusion de l'épisode 1 le")
+
+ println("Anime: $animeName")
+ println("Image: $animeImage")
+ println("Description: $animeDescription")
+ println("Genres: ${genres.joinToString(", ")}")
+ println("Simulcasted: $isSimulcasted")
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/plugins/HTTP.kt b/src/main/kotlin/fr/shikkanime/plugins/HTTP.kt
new file mode 100644
index 00000000..480cb7f5
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/plugins/HTTP.kt
@@ -0,0 +1,45 @@
+package fr.shikkanime.plugins
+
+import freemarker.cache.ClassTemplateLoader
+import io.ktor.http.*
+import io.ktor.serialization.gson.*
+import io.ktor.server.application.*
+import io.ktor.server.freemarker.*
+import io.ktor.server.plugins.compression.*
+import io.ktor.server.plugins.contentnegotiation.*
+import io.ktor.server.plugins.cors.routing.*
+import io.ktor.server.plugins.statuspages.*
+import io.ktor.server.response.*
+
+fun Application.configureHTTP() {
+ install(Compression) {
+ gzip {
+ priority = 1.0
+ }
+ deflate {
+ priority = 10.0
+ minimumSize(1024) // condition
+ }
+ }
+ install(CORS) {
+ allowMethod(HttpMethod.Options)
+ allowMethod(HttpMethod.Put)
+ allowMethod(HttpMethod.Delete)
+ allowMethod(HttpMethod.Patch)
+ allowHeader(HttpHeaders.Authorization)
+ allowHeader("MyCustomHeader")
+ anyHost() // @TODO: Don't do this in production if possible. Try to limit it.
+ }
+ install(StatusPages) {
+ exception { call, cause ->
+ call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
+ }
+ }
+ install(ContentNegotiation) {
+ gson {
+ }
+ }
+ install(FreeMarker) {
+ templateLoader = ClassTemplateLoader(this::class.java.classLoader, "templates")
+ }
+}
diff --git a/src/main/kotlin/fr/shikkanime/plugins/Routing.kt b/src/main/kotlin/fr/shikkanime/plugins/Routing.kt
new file mode 100644
index 00000000..e549075d
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/plugins/Routing.kt
@@ -0,0 +1,198 @@
+package fr.shikkanime.plugins
+
+import fr.shikkanime.dtos.PlatformDto
+import fr.shikkanime.entities.Link
+import fr.shikkanime.utils.Constant
+import fr.shikkanime.utils.routes.*
+import fr.ziedelth.utils.routes.method.Delete
+import fr.ziedelth.utils.routes.method.Get
+import fr.ziedelth.utils.routes.method.Post
+import fr.ziedelth.utils.routes.method.Put
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.auth.*
+import io.ktor.server.auth.jwt.*
+import io.ktor.server.freemarker.*
+import io.ktor.server.http.content.*
+import io.ktor.server.plugins.cachingheaders.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import io.ktor.util.*
+import java.util.*
+import kotlin.reflect.KFunction
+import kotlin.reflect.full.declaredFunctions
+import kotlin.reflect.full.findAnnotation
+import kotlin.reflect.full.hasAnnotation
+import kotlin.reflect.jvm.isAccessible
+import kotlin.reflect.jvm.javaType
+
+fun Application.configureRouting() {
+ routing {
+ staticResources("/assets", "assets")
+ createRoutes()
+ }
+}
+
+private fun Routing.createRoutes() {
+ Constant.reflections.getTypesAnnotatedWith(Controller::class.java).forEach { controllerClass ->
+ val controller = Constant.guice.getInstance(controllerClass)
+ createControllerRoutes(controller)
+ }
+}
+
+fun Routing.createControllerRoutes(controller: Any) {
+ val prefix = if (controller::class.hasAnnotation()) controller::class.findAnnotation()!!.value else ""
+ val kMethods = controller::class.declaredFunctions.filter { it.hasAnnotation() }.toMutableSet()
+
+ route(prefix) {
+ kMethods.forEach { method ->
+ val path = method.findAnnotation()!!.value
+
+ if (method.hasAnnotation()) {
+ val cached = method.findAnnotation()!!.maxAgeSeconds
+
+ install(CachingHeaders) {
+ options { _, _ -> io.ktor.http.content.CachingOptions(CacheControl.MaxAge(maxAgeSeconds = cached)) }
+ }
+ }
+
+ if (method.hasAnnotation()) {
+ authenticate("auth-jwt") {
+ handleMethods(method, prefix, controller, path)
+ }
+ } else {
+ handleMethods(method, prefix, controller, path)
+ }
+ }
+ }
+}
+
+private fun Route.handleMethods(
+ method: KFunction<*>,
+ prefix: String,
+ controller: Any,
+ path: String,
+) {
+ if (method.hasAnnotation()) {
+ get(path) {
+ handleRequest("GET", call, method, prefix, controller, path)
+ }
+ }
+
+ if (method.hasAnnotation()) {
+ post(path) {
+ handleRequest("POST", call, method, prefix, controller, path)
+ }
+ }
+
+ if (method.hasAnnotation()) {
+ put(path) {
+ handleRequest("PUT", call, method, prefix, controller, path)
+ }
+ }
+
+ if (method.hasAnnotation()) {
+ delete(path) {
+ handleRequest("DELETE", call, method, prefix, controller, path)
+ }
+ }
+}
+
+private suspend fun handleRequest(
+ httpMethod: String,
+ call: ApplicationCall,
+ method: KFunction<*>,
+ prefix: String,
+ controller: Any,
+ path: String
+) {
+ val parameters = call.parameters.toMap()
+ val replacedPath = replacePathWithParameters("$prefix$path", parameters)
+
+ println("$httpMethod $replacedPath")
+
+ try {
+ when (val response = callMethodWithParameters(method, controller, call, parameters)) {
+ is MultipartResponse -> {
+ call.respondBytes(response.image, response.contentType)
+ }
+
+ is TemplateResponse -> {
+ val map = response.model.toMutableMap()
+
+ map["links"] = Link.list().map { link ->
+ link.active = replacedPath.startsWith(link.href)
+ link
+ }
+
+ map["title"] = (if (!response.title.isNullOrBlank()) "${response.title} - " else "") + "Shikkanime"
+
+ call.respond(FreeMarkerContent(response.template, map, ""))
+ }
+
+ is RedirectResponse -> {
+ call.respondRedirect(response.path)
+ }
+
+ else -> {
+ call.respond(response.status, response.data ?: "")
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ call.respond(HttpStatusCode.BadRequest)
+ }
+}
+
+private fun replacePathWithParameters(path: String, parameters: Map>): String =
+ parameters.keys.fold(path) { acc, param ->
+ acc.replace("{$param}", parameters[param]!!.joinToString(", "))
+ }
+
+private suspend fun callMethodWithParameters(
+ method: KFunction<*>,
+ controller: Any,
+ call: ApplicationCall,
+ parameters: Map>
+): Response {
+ val methodParams = method.parameters.associateWith { kParameter ->
+ when {
+ kParameter.name.isNullOrBlank() -> {
+ controller
+ }
+
+ method.hasAnnotation() && kParameter.hasAnnotation() -> {
+ val jwtPrincipal = call.principal()
+ UUID.fromString(jwtPrincipal!!.payload.getClaim("uuid").asString())
+ }
+
+ kParameter.hasAnnotation() -> {
+ when (kParameter.type.javaType) {
+ Array::class.java -> call.receive>()
+ PlatformDto::class.java -> call.receive()
+ else -> call.receive()
+ }
+ }
+
+ kParameter.hasAnnotation() -> {
+ call.request.queryParameters[kParameter.name!!]
+ }
+
+ else -> {
+ val value = parameters[kParameter.name]!!.first()
+
+ val parsedValue: Any = when (kParameter.type.javaType) {
+ UUID::class.java -> UUID.fromString(value)
+ Int::class.java -> value.toInt()
+ else -> value
+ }
+
+ parsedValue
+ }
+ }
+ }
+
+ method.isAccessible = true
+ return method.callBy(methodParams) as Response
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/plugins/Security.kt b/src/main/kotlin/fr/shikkanime/plugins/Security.kt
new file mode 100644
index 00000000..c8642425
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/plugins/Security.kt
@@ -0,0 +1,30 @@
+package fr.shikkanime.plugins
+
+import com.auth0.jwt.JWT
+import com.auth0.jwt.algorithms.Algorithm
+import io.ktor.server.application.*
+import io.ktor.server.auth.*
+import io.ktor.server.auth.jwt.*
+
+fun Application.configureSecurity() {
+ // Please read the jwt property from the config file if you are using EngineMain
+ val jwtAudience = "jwt-audience"
+ val jwtDomain = "https://jwt-provider-domain/"
+ val jwtRealm = "ktor sample app"
+ val jwtSecret = "secret"
+ authentication {
+ jwt {
+ realm = jwtRealm
+ verifier(
+ JWT
+ .require(Algorithm.HMAC256(jwtSecret))
+ .withAudience(jwtAudience)
+ .withIssuer(jwtDomain)
+ .build()
+ )
+ validate { credential ->
+ if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt
new file mode 100644
index 00000000..7e876886
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt
@@ -0,0 +1,62 @@
+package fr.shikkanime.repositories
+
+import com.google.inject.Inject
+import fr.shikkanime.entities.ShikkEntity
+import fr.shikkanime.utils.Database
+import java.lang.reflect.ParameterizedType
+import java.util.*
+
+abstract class AbstractRepository {
+ @Inject
+ protected lateinit var database: Database
+
+ protected fun getEntityClass(): Class {
+ return (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class
+ }
+
+ protected fun getEntityManager() = database.entityManager
+
+ protected fun inTransaction(block: () -> T): T {
+ val transaction = getEntityManager().transaction
+ transaction.begin()
+ val result: T?
+
+ try {
+ result = block()
+ transaction.commit()
+ } catch (e: Exception) {
+ transaction.rollback()
+ throw e
+ }
+
+ return result
+ }
+
+ fun findAll(): List {
+ return getEntityManager().createQuery("FROM ${getEntityClass().simpleName}", getEntityClass()).resultList
+ }
+
+ fun find(uuid: UUID): E? {
+ return getEntityManager().find(getEntityClass(), uuid)
+ }
+
+ fun save(entity: E): E {
+ return inTransaction {
+ getEntityManager().persist(entity)
+ entity
+ }
+ }
+
+ fun update(entity: E): E {
+ return inTransaction {
+ getEntityManager().merge(entity)
+ entity
+ }
+ }
+
+ fun delete(entity: E) {
+ inTransaction {
+ getEntityManager().remove(entity)
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/repositories/CountryRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/CountryRepository.kt
new file mode 100644
index 00000000..176097da
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/repositories/CountryRepository.kt
@@ -0,0 +1,25 @@
+package fr.shikkanime.repositories
+
+import fr.shikkanime.entities.Country
+
+class CountryRepository : AbstractRepository() {
+ fun findByName(name: String): Country? {
+ return getEntityManager().createQuery("FROM Country WHERE name = :name", getEntityClass())
+ .setParameter("name", name)
+ .resultList
+ .firstOrNull()
+ }
+
+ fun findByCode(code: String): Country? {
+ return getEntityManager().createQuery("FROM Country WHERE countryCode = :code", getEntityClass())
+ .setParameter("code", code)
+ .resultList
+ .firstOrNull()
+ }
+
+ fun findAllByCode(codes: List): List {
+ return getEntityManager().createQuery("FROM Country WHERE countryCode IN :codes", getEntityClass())
+ .setParameter("codes", codes)
+ .resultList
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt
new file mode 100644
index 00000000..f763096f
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt
@@ -0,0 +1,26 @@
+package fr.shikkanime.repositories
+
+import fr.shikkanime.entities.Metric
+import java.time.ZonedDateTime
+
+class MetricRepository : AbstractRepository() {
+ fun findAllAfter(date: ZonedDateTime): List {
+ return getEntityManager().createQuery("FROM Metric WHERE date > :date", getEntityClass())
+ .setParameter("date", date)
+ .resultList
+ }
+
+ fun getAverageCpuLoad(from: ZonedDateTime, to: ZonedDateTime): Double? {
+ return getEntityManager().createQuery("SELECT AVG(cpuLoad) FROM Metric WHERE date BETWEEN :from AND :to", Double::class.java)
+ .setParameter("from", from)
+ .setParameter("to", to)
+ .singleResult
+ }
+
+ fun getAverageMemoryUsage(from: ZonedDateTime, to: ZonedDateTime): Double? {
+ return getEntityManager().createQuery("SELECT AVG(memoryUsage) FROM Metric WHERE date BETWEEN :from AND :to", Double::class.java)
+ .setParameter("from", from)
+ .setParameter("to", to)
+ .singleResult
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/repositories/PlatformRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/PlatformRepository.kt
new file mode 100644
index 00000000..3cc6e58e
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/repositories/PlatformRepository.kt
@@ -0,0 +1,12 @@
+package fr.shikkanime.repositories
+
+import fr.shikkanime.entities.Platform
+
+class PlatformRepository : AbstractRepository() {
+ fun findByName(name: String): Platform? {
+ return getEntityManager().createQuery("FROM Platform WHERE name = :name", getEntityClass())
+ .setParameter("name", name)
+ .resultList
+ .firstOrNull()
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/services/AbstractService.kt b/src/main/kotlin/fr/shikkanime/services/AbstractService.kt
new file mode 100644
index 00000000..8d628f11
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/services/AbstractService.kt
@@ -0,0 +1,19 @@
+package fr.shikkanime.services
+
+import fr.shikkanime.entities.ShikkEntity
+import fr.shikkanime.repositories.AbstractRepository
+import java.util.*
+
+abstract class AbstractService> {
+ protected abstract fun getRepository(): R
+
+ fun findAll() = getRepository().findAll()
+
+ fun find(uuid: UUID) = getRepository().find(uuid)
+
+ open fun save(entity: E) = getRepository().save(entity)
+
+ fun update(entity: E) = getRepository().update(entity)
+
+ fun delete(entity: E) = getRepository().delete(entity)
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/services/CountryService.kt b/src/main/kotlin/fr/shikkanime/services/CountryService.kt
new file mode 100644
index 00000000..af81c92c
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/services/CountryService.kt
@@ -0,0 +1,31 @@
+package fr.shikkanime.services
+
+import com.google.inject.Inject
+import fr.shikkanime.entities.Country
+import fr.shikkanime.repositories.CountryRepository
+import fr.shikkanime.utils.MapCache
+
+class CountryService : AbstractService() {
+ @Inject
+ private lateinit var countryRepository: CountryRepository
+
+ private val codesCache: MapCache, List> = MapCache {
+ countryRepository.findAllByCode(it)
+ }
+
+ override fun getRepository(): CountryRepository {
+ return countryRepository
+ }
+
+ fun findByName(name: String): Country? {
+ return countryRepository.findByName(name)
+ }
+
+ fun findByCode(code: String): Country? {
+ return countryRepository.findByCode(code)
+ }
+
+ fun findAllByCode(codes: List): List {
+ return codesCache[codes]
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/services/MetricService.kt b/src/main/kotlin/fr/shikkanime/services/MetricService.kt
new file mode 100644
index 00000000..2d78df4a
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/services/MetricService.kt
@@ -0,0 +1,43 @@
+package fr.shikkanime.services
+
+import com.google.inject.Inject
+import fr.shikkanime.caches.FromToZonedDateTimeKeyCache
+import fr.shikkanime.entities.Metric
+import fr.shikkanime.repositories.MetricRepository
+import fr.shikkanime.utils.MapCache
+import java.time.Duration
+import java.time.ZonedDateTime
+import java.time.temporal.ChronoUnit
+
+class MetricService : AbstractService() {
+ @Inject
+ private lateinit var metricRepository: MetricRepository
+
+ private val averageCpuLoadCache = MapCache(
+ Duration.of(1, ChronoUnit.HOURS)
+ ) {
+ metricRepository.getAverageCpuLoad(it.from, it.to)
+ }
+
+ private val averageMemoryUsageCache = MapCache(
+ Duration.of(1, ChronoUnit.HOURS)
+ ) {
+ metricRepository.getAverageMemoryUsage(it.from, it.to)
+ }
+
+ override fun getRepository(): MetricRepository {
+ return metricRepository
+ }
+
+ fun findAllAfter(date: ZonedDateTime): List {
+ return metricRepository.findAllAfter(date)
+ }
+
+ fun getAverageCpuLoad(from: ZonedDateTime, to: ZonedDateTime): Double? {
+ return averageCpuLoadCache[FromToZonedDateTimeKeyCache(from, to)]
+ }
+
+ fun getAverageMemoryUsage(from: ZonedDateTime, to: ZonedDateTime): Double? {
+ return averageMemoryUsageCache[FromToZonedDateTimeKeyCache(from, to)]
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/services/PlatformService.kt b/src/main/kotlin/fr/shikkanime/services/PlatformService.kt
new file mode 100644
index 00000000..904cc5c6
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/services/PlatformService.kt
@@ -0,0 +1,18 @@
+package fr.shikkanime.services
+
+import com.google.inject.Inject
+import fr.shikkanime.entities.Platform
+import fr.shikkanime.repositories.PlatformRepository
+
+class PlatformService : AbstractService() {
+ @Inject
+ private lateinit var platformRepository: PlatformRepository
+
+ override fun getRepository(): PlatformRepository {
+ return platformRepository
+ }
+
+ fun findByName(name: String): Platform? {
+ return platformRepository.findByName(name)
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/utils/Constant.kt b/src/main/kotlin/fr/shikkanime/utils/Constant.kt
new file mode 100644
index 00000000..9b24cb5c
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/Constant.kt
@@ -0,0 +1,10 @@
+package fr.shikkanime.utils
+
+import com.google.inject.Guice
+import fr.shikkanime.modules.DefaultModule
+import org.reflections.Reflections
+
+object Constant {
+ val reflections = Reflections("fr.shikkanime")
+ val guice = Guice.createInjector(DefaultModule())
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/utils/Database.kt b/src/main/kotlin/fr/shikkanime/utils/Database.kt
new file mode 100644
index 00000000..01ab9560
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/Database.kt
@@ -0,0 +1,55 @@
+package fr.shikkanime.utils
+
+import fr.shikkanime.entities.ShikkEntity
+import jakarta.persistence.EntityManager
+import liquibase.Contexts
+import liquibase.LabelExpression
+import liquibase.Liquibase
+import liquibase.database.DatabaseFactory
+import liquibase.database.jvm.JdbcConnection
+import liquibase.resource.ClassLoaderResourceAccessor
+import org.hibernate.cfg.Configuration
+import java.io.File
+import kotlin.system.exitProcess
+
+class Database {
+ val entityManager: EntityManager
+
+ constructor(file: File) {
+ if (!file.exists()) {
+ throw Exception("File ${file.absolutePath} does not exist")
+ }
+
+ val configuration = Configuration()
+ val entities = Constant.reflections.getSubTypesOf(ShikkEntity::class.java)
+ entities.forEach { configuration.addAnnotatedClass(it) }
+ configuration.configure(file)
+ val buildSessionFactory = configuration.buildSessionFactory()
+ entityManager = buildSessionFactory.createEntityManager()
+
+ buildSessionFactory.openSession().doWork {
+ val database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(it))
+ val liquibase = Liquibase("db/changelog/db.changelog-master.xml", ClassLoaderResourceAccessor(), database)
+
+ try {
+ liquibase.update(Contexts(), LabelExpression())
+ } catch (e: Exception) {
+ e.printStackTrace()
+ exitProcess(1)
+ }
+ }
+
+ try {
+ buildSessionFactory.schemaManager.validateMappedObjects()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ exitProcess(1)
+ }
+ }
+
+ constructor() : this(File("hibernate.cfg.xml"))
+
+ fun getSize(): Long {
+ return entityManager.createNativeQuery("SELECT pg_database_size('shikkanime')").singleResult as Long
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/utils/HttpRequest.kt b/src/main/kotlin/fr/shikkanime/utils/HttpRequest.kt
new file mode 100644
index 00000000..bbc8c7f3
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/HttpRequest.kt
@@ -0,0 +1,19 @@
+package fr.shikkanime.utils
+
+import io.ktor.client.*
+import io.ktor.client.engine.cio.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+
+
+class HttpRequest {
+ suspend fun get(url: String): HttpResponse {
+ val httpClient = HttpClient(CIO)
+ println("Making request to $url... (GET)")
+ val start = System.currentTimeMillis()
+ val response = httpClient.get(url)
+ httpClient.close()
+ println("Request to $url done in ${System.currentTimeMillis() - start}ms (GET)")
+ return response
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/utils/JobManager.kt b/src/main/kotlin/fr/shikkanime/utils/JobManager.kt
new file mode 100644
index 00000000..e825b4cd
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/JobManager.kt
@@ -0,0 +1,35 @@
+package fr.shikkanime.utils
+
+import fr.shikkanime.jobs.AbstractJob
+import org.quartz.*
+import org.quartz.impl.StdSchedulerFactory
+
+object JobManager {
+ private val scheduler = StdSchedulerFactory().scheduler
+
+ fun scheduleJob(cronExpression: String, jobClass: Class) {
+ val jobDetail = JobBuilder.newJob(JobExecutor::class.java)
+ .withIdentity(jobClass.name)
+ .build()
+
+ val trigger = TriggerBuilder.newTrigger()
+ .withIdentity(jobClass.name)
+ .withSchedule(CronScheduleBuilder.cronSchedule(cronExpression))
+ .build()
+
+ scheduler.scheduleJob(jobDetail, trigger)
+ }
+
+ fun start() {
+ scheduler.start()
+ }
+
+ class JobExecutor : Job {
+ override fun execute(context: JobExecutionContext?) {
+ val jobName = context?.jobDetail?.key?.name ?: return
+ val `class` = Class.forName(jobName) ?: return
+ val job = Constant.guice.getInstance(`class`) as? AbstractJob ?: return
+ job.run()
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/utils/MapCache.kt b/src/main/kotlin/fr/shikkanime/utils/MapCache.kt
new file mode 100644
index 00000000..faf499b5
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/MapCache.kt
@@ -0,0 +1,45 @@
+package fr.shikkanime.utils
+
+import com.google.common.cache.CacheBuilder
+import com.google.common.cache.CacheLoader
+import com.google.common.cache.LoadingCache
+import java.time.Duration
+
+class MapCache(
+ duration: Duration? = null,
+ private val classes: List> = listOf(),
+ private val block: (K) -> V,
+) {
+ private val cache: LoadingCache
+
+ init {
+ val builder = CacheBuilder.newBuilder()
+
+ if (duration != null) {
+ builder.expireAfterWrite(duration)
+ }
+
+ cache = builder
+ .build(object : CacheLoader() {
+ override fun load(key: K): V {
+ return block(key)
+ }
+ })
+
+ caches.add(this)
+ }
+
+ operator fun get(key: K): V {
+ return cache.getUnchecked(key)
+ }
+
+ companion object {
+ private val caches: MutableList> = mutableListOf()
+
+ fun invalidate(vararg classes: Class<*>) {
+ classes.forEach { clazz ->
+ caches.filter { it.classes.contains(clazz) }.forEach { it.cache.invalidateAll() }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/Authenticated.kt b/src/main/kotlin/fr/shikkanime/utils/routes/Authenticated.kt
new file mode 100644
index 00000000..e7ef84f2
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/routes/Authenticated.kt
@@ -0,0 +1,5 @@
+package fr.shikkanime.utils.routes
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.FUNCTION)
+annotation class Authenticated
diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/BodyParam.kt b/src/main/kotlin/fr/shikkanime/utils/routes/BodyParam.kt
new file mode 100644
index 00000000..0a5d2470
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/routes/BodyParam.kt
@@ -0,0 +1,5 @@
+package fr.shikkanime.utils.routes
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.VALUE_PARAMETER)
+annotation class BodyParam
diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/Cached.kt b/src/main/kotlin/fr/shikkanime/utils/routes/Cached.kt
new file mode 100644
index 00000000..78da24ab
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/routes/Cached.kt
@@ -0,0 +1,5 @@
+package fr.shikkanime.utils.routes
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.FUNCTION)
+annotation class Cached(val maxAgeSeconds: Int)
diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/Controller.kt b/src/main/kotlin/fr/shikkanime/utils/routes/Controller.kt
new file mode 100644
index 00000000..948b1edd
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/routes/Controller.kt
@@ -0,0 +1,5 @@
+package fr.shikkanime.utils.routes
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.CLASS)
+annotation class Controller(val value: String = "")
diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/JWTUser.kt b/src/main/kotlin/fr/shikkanime/utils/routes/JWTUser.kt
new file mode 100644
index 00000000..3ff272c5
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/routes/JWTUser.kt
@@ -0,0 +1,5 @@
+package fr.shikkanime.utils.routes
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.VALUE_PARAMETER)
+annotation class JWTUser
diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/Path.kt b/src/main/kotlin/fr/shikkanime/utils/routes/Path.kt
new file mode 100644
index 00000000..e10c921f
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/routes/Path.kt
@@ -0,0 +1,5 @@
+package fr.shikkanime.utils.routes
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.FUNCTION)
+annotation class Path(val value: String = "")
diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/QueryParam.kt b/src/main/kotlin/fr/shikkanime/utils/routes/QueryParam.kt
new file mode 100644
index 00000000..52eea718
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/routes/QueryParam.kt
@@ -0,0 +1,5 @@
+package fr.shikkanime.utils.routes
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.VALUE_PARAMETER)
+annotation class QueryParam
diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt b/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt
new file mode 100644
index 00000000..18720a06
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt
@@ -0,0 +1,29 @@
+package fr.shikkanime.utils.routes
+
+import io.ktor.http.*
+
+open class Response(
+ val status: HttpStatusCode = HttpStatusCode.OK,
+ val data: Any? = null,
+) {
+ companion object {
+ fun ok(data: Any? = null): Response = Response(HttpStatusCode.OK, data)
+ fun created(data: Any?): Response = Response(HttpStatusCode.Created, data)
+ fun noContent(): Response = Response(HttpStatusCode.NoContent)
+ }
+}
+
+open class MultipartResponse(
+ val image: ByteArray,
+ val contentType: ContentType,
+) : Response()
+
+open class TemplateResponse(
+ val template: String,
+ val title: String? = null,
+ val model: MutableMap = mutableMapOf(),
+) : Response()
+
+open class RedirectResponse(
+ val path: String,
+) : Response(HttpStatusCode.Found)
\ No newline at end of file
diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/method/Delete.kt b/src/main/kotlin/fr/shikkanime/utils/routes/method/Delete.kt
new file mode 100644
index 00000000..94fb1faa
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/routes/method/Delete.kt
@@ -0,0 +1,5 @@
+package fr.ziedelth.utils.routes.method
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.FUNCTION)
+annotation class Delete
diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/method/Get.kt b/src/main/kotlin/fr/shikkanime/utils/routes/method/Get.kt
new file mode 100644
index 00000000..529f8165
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/routes/method/Get.kt
@@ -0,0 +1,5 @@
+package fr.ziedelth.utils.routes.method
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.FUNCTION)
+annotation class Get
diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/method/Post.kt b/src/main/kotlin/fr/shikkanime/utils/routes/method/Post.kt
new file mode 100644
index 00000000..e91da823
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/routes/method/Post.kt
@@ -0,0 +1,5 @@
+package fr.ziedelth.utils.routes.method
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.FUNCTION)
+annotation class Post
diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/method/Put.kt b/src/main/kotlin/fr/shikkanime/utils/routes/method/Put.kt
new file mode 100644
index 00000000..4044a979
--- /dev/null
+++ b/src/main/kotlin/fr/shikkanime/utils/routes/method/Put.kt
@@ -0,0 +1,5 @@
+package fr.ziedelth.utils.routes.method
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.FUNCTION)
+annotation class Put
diff --git a/src/main/resources/assets/favicons/favicon-16x16.png b/src/main/resources/assets/favicons/favicon-16x16.png
new file mode 100644
index 00000000..12ea9292
Binary files /dev/null and b/src/main/resources/assets/favicons/favicon-16x16.png differ
diff --git a/src/main/resources/assets/favicons/favicon-32x32.png b/src/main/resources/assets/favicons/favicon-32x32.png
new file mode 100644
index 00000000..87d56b63
Binary files /dev/null and b/src/main/resources/assets/favicons/favicon-32x32.png differ
diff --git a/src/main/resources/assets/favicons/favicon.ico b/src/main/resources/assets/favicons/favicon.ico
new file mode 100644
index 00000000..b37372b5
Binary files /dev/null and b/src/main/resources/assets/favicons/favicon.ico differ
diff --git a/src/main/resources/assets/icon.jpg b/src/main/resources/assets/icon.jpg
new file mode 100644
index 00000000..e67068af
Binary files /dev/null and b/src/main/resources/assets/icon.jpg differ
diff --git a/src/main/resources/assets/js/admin/home_chart.js b/src/main/resources/assets/js/admin/home_chart.js
new file mode 100644
index 00000000..9ba94cf9
--- /dev/null
+++ b/src/main/resources/assets/js/admin/home_chart.js
@@ -0,0 +1,111 @@
+const cpuChartElement = document.getElementById('cpuLoadChart').getContext('2d');
+const memoryChartElement = document.getElementById('memoryUsageChart').getContext('2d');
+
+const cpuChart = new Chart(cpuChartElement, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [
+ {
+ label: '% CPU',
+ data: [],
+ backgroundColor: ['rgba(255, 99, 132, .2)'],
+ borderColor: ['rgba(255, 99, 132, 1)'],
+ tension: 0.1
+ },
+ {
+ label: '% CPU (average)',
+ data: [],
+ backgroundColor: ['rgba(54, 162, 235, .2)'],
+ borderColor: ['rgba(54, 162, 235, 1)'],
+ tension: 0.1
+ }
+ ]
+ },
+ options: {
+ maintainAspectRatio: false,
+ scales: {
+ y: {
+ beginAtZero: true
+ }
+ },
+ elements: {
+ point: {
+ radius: 0
+ }
+ },
+ animation: {
+ duration: 0
+ }
+ }
+});
+
+const memoryChart = new Chart(memoryChartElement, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [
+ {
+ label: 'RAM in MB',
+ data: [],
+ backgroundColor: ['rgba(255, 99, 132, .2)'],
+ borderColor: ['rgba(255, 99, 132, 1)'],
+ tension: 0.1
+ },
+ {
+ label: 'RAM in MB (average)',
+ data: [],
+ backgroundColor: ['rgba(54, 162, 235, .2)'],
+ borderColor: ['rgba(54, 162, 235, 1)'],
+ tension: 0.1
+ }
+ ]
+ },
+ options: {
+ maintainAspectRatio: false,
+ scales: {
+ y: {
+ beginAtZero: true
+ }
+ },
+ elements: {
+ point: {
+ radius: 0
+ }
+ },
+ animation: {
+ duration: 0
+ }
+ }
+});
+
+document.addEventListener('DOMContentLoaded', async () => {
+ await setChartData();
+
+ setInterval(async () => {
+ await setChartData();
+ }, 10000);
+});
+
+async function getMetrics() {
+ return await fetch('/api/metrics')
+ .then(response => response.json())
+ .catch(error => console.error(error));
+}
+
+async function setChartData() {
+ const data = await getMetrics();
+
+ cpuChart.data.labels = data.map(metric => metric.date);
+ cpuChart.data.datasets[0].data = data.map(metric => metric.cpuLoad);
+ cpuChart.data.datasets[1].data = data.filter(metric => metric.averageCpuLoad !== "0").map(metric => metric.averageCpuLoad);
+ cpuChart.update();
+
+ memoryChart.data.labels = data.map(metric => metric.date);
+ memoryChart.data.datasets[0].data = data.map(metric => metric.memoryUsage);
+ memoryChart.data.datasets[1].data = data.filter(metric => metric.averageMemoryUsage !== "0").map(metric => metric.averageMemoryUsage);
+ memoryChart.update();
+
+ const lastMetric = data[data.length - 1];
+ document.getElementById('dbSize').innerText = lastMetric.databaseSize + ' MB';
+}
\ No newline at end of file
diff --git a/src/main/resources/db/changelog/2023/12/01-changelog.xml b/src/main/resources/db/changelog/2023/12/01-changelog.xml
new file mode 100644
index 00000000..9a738e7a
--- /dev/null
+++ b/src/main/resources/db/changelog/2023/12/01-changelog.xml
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM country WHERE country_code = 'FR'
+
+
+
+
+ France
+ FR
+
+
+
\ No newline at end of file
diff --git a/src/main/resources/db/changelog/db.changelog-master.xml b/src/main/resources/db/changelog/db.changelog-master.xml
new file mode 100644
index 00000000..a1282c8f
--- /dev/null
+++ b/src/main/resources/db/changelog/db.changelog-master.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/main/resources/templates/_freemarker_implicit.ftl b/src/main/resources/templates/_freemarker_implicit.ftl
new file mode 100644
index 00000000..3fa3f85d
--- /dev/null
+++ b/src/main/resources/templates/_freemarker_implicit.ftl
@@ -0,0 +1,5 @@
+[#ftl]
+[#-- @implicitly included --]
+[#-- @ftlvariable name="title" type="java.lang.String" --]
+[#-- @ftlvariable name="metrics" type="kotlin.collections.AbstractList" --]
+[#-- @ftlvariable name="links" type="kotlin.collections.AbstractList" --]
diff --git a/src/main/resources/templates/admin/_layout.ftl b/src/main/resources/templates/admin/_layout.ftl
new file mode 100644
index 00000000..15fd11d6
--- /dev/null
+++ b/src/main/resources/templates/admin/_layout.ftl
@@ -0,0 +1,61 @@
+<#macro main>
+
+
+
+
+
+ ${title}
+
+ <#-- Favicons -->
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <#nested>
+
+
+
+
+
+
+#macro>
\ No newline at end of file
diff --git a/src/main/resources/templates/admin/dashboard.ftl b/src/main/resources/templates/admin/dashboard.ftl
new file mode 100644
index 00000000..25309e6e
--- /dev/null
+++ b/src/main/resources/templates/admin/dashboard.ftl
@@ -0,0 +1,16 @@
+<#import "_layout.ftl" as layout />
+<@layout.main>
+ Database current size:
+
+
+
+
+
+@layout.main>
\ No newline at end of file
diff --git a/src/main/resources/templates/admin/platforms.ftl b/src/main/resources/templates/admin/platforms.ftl
new file mode 100644
index 00000000..c078bd74
--- /dev/null
+++ b/src/main/resources/templates/admin/platforms.ftl
@@ -0,0 +1,4 @@
+<#import "_layout.ftl" as layout />
+<@layout.main>
+
+@layout.main>
\ No newline at end of file
diff --git a/src/test/kotlin/fr/shikkanime/ApplicationTest.kt b/src/test/kotlin/fr/shikkanime/ApplicationTest.kt
new file mode 100644
index 00000000..30094fe8
--- /dev/null
+++ b/src/test/kotlin/fr/shikkanime/ApplicationTest.kt
@@ -0,0 +1,22 @@
+package fr.shikkanime
+
+import fr.shikkanime.plugins.configureRouting
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import io.ktor.server.testing.*
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ApplicationTest {
+ @Test
+ fun testRoot() = testApplication {
+ application {
+ configureRouting()
+ }
+ client.get("/").apply {
+ assertEquals(HttpStatusCode.OK, status)
+ assertEquals("Hello World!", bodyAsText())
+ }
+ }
+}