From 03842ab1f95ad3b7d7b304988c1865f894896574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9luchu?= Date: Sun, 6 Jul 2025 18:17:51 +0200 Subject: [PATCH] Improves and include new endpoints New enpoints for seasons and for types --- .../jeluchu/core/enums/AnimeStatusTypes.kt | 11 +++ .../kotlin/com/jeluchu/core/enums/Season.kt | 8 ++ .../jeluchu/core/messages/ErrorMessages.kt | 1 + .../com/jeluchu/core/utils/CommonUtils.kt | 47 ++++++++++ .../com/jeluchu/core/utils/Constants.kt | 4 +- .../com/jeluchu/core/utils/SeasonCalendar.kt | 22 +++++ .../features/anime/mappers/SeasonMappers.kt | 13 +++ .../anime/models/seasons/SeasonAnimeEntity.kt | 11 +++ .../anime/models/seasons/YearSeasons.kt | 10 ++ .../features/anime/routes/AnimeRoutes.kt | 9 +- .../features/anime/services/AnimeService.kt | 67 +++++++++----- .../anime/services/DirectoryService.kt | 83 +---------------- .../features/anime/services/SeasonService.kt | 92 +++++++++++++++++++ 13 files changed, 274 insertions(+), 104 deletions(-) create mode 100644 src/main/kotlin/com/jeluchu/core/enums/AnimeStatusTypes.kt create mode 100644 src/main/kotlin/com/jeluchu/core/enums/Season.kt create mode 100644 src/main/kotlin/com/jeluchu/core/utils/CommonUtils.kt create mode 100644 src/main/kotlin/com/jeluchu/core/utils/SeasonCalendar.kt create mode 100644 src/main/kotlin/com/jeluchu/features/anime/mappers/SeasonMappers.kt create mode 100644 src/main/kotlin/com/jeluchu/features/anime/models/seasons/SeasonAnimeEntity.kt create mode 100644 src/main/kotlin/com/jeluchu/features/anime/models/seasons/YearSeasons.kt create mode 100644 src/main/kotlin/com/jeluchu/features/anime/services/SeasonService.kt diff --git a/src/main/kotlin/com/jeluchu/core/enums/AnimeStatusTypes.kt b/src/main/kotlin/com/jeluchu/core/enums/AnimeStatusTypes.kt new file mode 100644 index 0000000..6b600fc --- /dev/null +++ b/src/main/kotlin/com/jeluchu/core/enums/AnimeStatusTypes.kt @@ -0,0 +1,11 @@ +package com.jeluchu.core.enums + +import kotlinx.serialization.Serializable + +@Serializable +enum class AnimeStatusTypes { + FINISHED, ONGOING, UPCOMING, UNKNOWN +} + +val animeStatusTypesErrorList = AnimeStatusTypes.entries.joinToString(", ") { it.name.lowercase() } +fun parseAnimeStatusType(type: String) = AnimeStatusTypes.entries.firstOrNull { it.name.equals(type, ignoreCase = true) } \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/core/enums/Season.kt b/src/main/kotlin/com/jeluchu/core/enums/Season.kt new file mode 100644 index 0000000..30db912 --- /dev/null +++ b/src/main/kotlin/com/jeluchu/core/enums/Season.kt @@ -0,0 +1,8 @@ +package com.jeluchu.core.enums + +enum class Season { + WINTER, SPRING, SUMMER, FALL +} + +val seasonsErrorList = AnimeTypes.entries.joinToString(", ") { it.name.lowercase() } +fun parseSeasons(type: String) = Season.entries.firstOrNull { it.name.equals(type, ignoreCase = true) } \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/core/messages/ErrorMessages.kt b/src/main/kotlin/com/jeluchu/core/messages/ErrorMessages.kt index 36ac709..5a5e21c 100644 --- a/src/main/kotlin/com/jeluchu/core/messages/ErrorMessages.kt +++ b/src/main/kotlin/com/jeluchu/core/messages/ErrorMessages.kt @@ -12,6 +12,7 @@ sealed class ErrorMessages(val message: String) { data object InvalidMangaType : ErrorMessages("Invalid 'type' parameter. Valid values are: ${MangaTypes.entries.joinToString(", ") { it.name.lowercase() }}") data object InvalidSizeAndPage : ErrorMessages("Invalid page and size parameters") data object InvalidTopAnimeType : ErrorMessages("Invalid 'type' parameter. Valid values are: $animeTypesErrorList") + data object InvalidAnimeStatusType : ErrorMessages("Invalid 'status' parameter. Valid values are: $animeStatusTypesErrorList") data object InvalidTopAnimeFilterType : ErrorMessages("Invalid 'type' parameter. Valid values are: $animeFilterTypesErrorList") data object InvalidTopMangaType : ErrorMessages("Invalid 'type' parameter. Valid values are: $mangaTypesErrorList") data object InvalidTopMangaFilterType : ErrorMessages("Invalid 'type' parameter. Valid values are: $mangaFilterTypesErrorList") diff --git a/src/main/kotlin/com/jeluchu/core/utils/CommonUtils.kt b/src/main/kotlin/com/jeluchu/core/utils/CommonUtils.kt new file mode 100644 index 0000000..c4c8a5f --- /dev/null +++ b/src/main/kotlin/com/jeluchu/core/utils/CommonUtils.kt @@ -0,0 +1,47 @@ +package com.jeluchu.core.utils + +import com.jeluchu.core.models.PaginationResponse +import com.mongodb.client.MongoCollection +import org.bson.Document +import org.bson.conversions.Bson + +fun getRemoteData( + filters: Bson, + mapper: (Document) -> T, + onQuerySuccess: (List) -> Unit, + newCollection: MongoCollection, + remoteCollection: MongoCollection, +) { + newCollection.deleteMany(Document()) + + val query = remoteCollection + .find(filters) + .toList() + .map { mapper(it) } + + onQuerySuccess(query) +} + +suspend fun getLocalData( + page: Int, + size: Int, + skipCount: Int, + mapper: (Document) -> T, + collection: MongoCollection, + onQuerySuccess: suspend (PaginationResponse) -> Unit +) { + val query = collection + .find() + .skip(skipCount) + .limit(size) + .toList() + .map { mapper(it) } + + val paginate = PaginationResponse( + page = page, + data = query, + size = query.size + ) + + onQuerySuccess(paginate) +} \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/core/utils/Constants.kt b/src/main/kotlin/com/jeluchu/core/utils/Constants.kt index d4923cf..89e997d 100644 --- a/src/main/kotlin/com/jeluchu/core/utils/Constants.kt +++ b/src/main/kotlin/com/jeluchu/core/utils/Constants.kt @@ -69,9 +69,11 @@ object Routes { const val EPISODES = "/episodes" const val ID = "/{id}" const val TYPE = "/{type}" - const val SEASON = "/{year}/{season}" + const val SEASON = "/season" + const val SEASON_PARAMS = "/{year}/{season}" const val DAY = "/{day}" const val THEMES = "/themes" + const val YEAR_INDEX = "/yearIndex" } object TimerKey { diff --git a/src/main/kotlin/com/jeluchu/core/utils/SeasonCalendar.kt b/src/main/kotlin/com/jeluchu/core/utils/SeasonCalendar.kt new file mode 100644 index 0000000..e635f98 --- /dev/null +++ b/src/main/kotlin/com/jeluchu/core/utils/SeasonCalendar.kt @@ -0,0 +1,22 @@ +package com.jeluchu.core.utils + +import com.jeluchu.core.enums.Season +import java.util.Calendar +import java.util.Locale + +object SeasonCalendar { + private val calendar: Calendar by lazy { + Calendar.getInstance(Locale.getDefault()) + } + + val currentYear = calendar.get(Calendar.YEAR) + private val month = calendar.get(Calendar.MONTH) + + val currentSeason = when (month) { + 0, 1, 11 -> Season.WINTER + 2, 3, 4 -> Season.SPRING + 5, 6, 7 -> Season.SUMMER + 8, 9, 10 -> Season.FALL + else -> Season.SPRING + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/anime/mappers/SeasonMappers.kt b/src/main/kotlin/com/jeluchu/features/anime/mappers/SeasonMappers.kt new file mode 100644 index 0000000..b595081 --- /dev/null +++ b/src/main/kotlin/com/jeluchu/features/anime/mappers/SeasonMappers.kt @@ -0,0 +1,13 @@ +package com.jeluchu.features.anime.mappers + +import com.jeluchu.core.extensions.getIntSafe +import com.jeluchu.core.extensions.getStringSafe +import com.jeluchu.features.anime.models.seasons.SeasonAnimeEntity +import org.bson.Document + +fun documentToSeasonEntity(doc: Document) = SeasonAnimeEntity( + score = doc.getStringSafe("score"), + malId = doc.getIntSafe("malId"), + image = doc.getStringSafe("poster"), + title = doc.getStringSafe("title") +) \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/anime/models/seasons/SeasonAnimeEntity.kt b/src/main/kotlin/com/jeluchu/features/anime/models/seasons/SeasonAnimeEntity.kt new file mode 100644 index 0000000..534c45d --- /dev/null +++ b/src/main/kotlin/com/jeluchu/features/anime/models/seasons/SeasonAnimeEntity.kt @@ -0,0 +1,11 @@ +package com.jeluchu.features.anime.models.seasons + +import kotlinx.serialization.Serializable + +@Serializable +data class SeasonAnimeEntity( + val malId: Int, + val title: String, + val image: String, + val score: String, +) \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/anime/models/seasons/YearSeasons.kt b/src/main/kotlin/com/jeluchu/features/anime/models/seasons/YearSeasons.kt new file mode 100644 index 0000000..6cb7574 --- /dev/null +++ b/src/main/kotlin/com/jeluchu/features/anime/models/seasons/YearSeasons.kt @@ -0,0 +1,10 @@ +package com.jeluchu.features.anime.models.seasons + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class YearSeasons( + @SerialName("year") val year: Int, + @SerialName("seasons") val seasons: List +) \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/anime/routes/AnimeRoutes.kt b/src/main/kotlin/com/jeluchu/features/anime/routes/AnimeRoutes.kt index fab75f3..58898f3 100644 --- a/src/main/kotlin/com/jeluchu/features/anime/routes/AnimeRoutes.kt +++ b/src/main/kotlin/com/jeluchu/features/anime/routes/AnimeRoutes.kt @@ -4,20 +4,27 @@ import com.jeluchu.core.extensions.getToJson import com.jeluchu.core.utils.Routes import com.jeluchu.features.anime.services.AnimeService import com.jeluchu.features.anime.services.DirectoryService +import com.jeluchu.features.anime.services.SeasonService import com.mongodb.client.MongoDatabase import io.ktor.server.routing.* fun Route.animeEndpoints( mongoDatabase: MongoDatabase, service: AnimeService = AnimeService(mongoDatabase), + seasonService: SeasonService = SeasonService(mongoDatabase), directoryService: DirectoryService = DirectoryService(mongoDatabase), ) = route(Routes.ANIME) { + getToJson { service.getAnimeByType(call) } getToJson(Routes.ID) { service.getAnimeByMalId(call) } getToJson(Routes.LAST_EPISODES) { service.getLastEpisodes(call) } + route(Routes.SEASON) { + getToJson { seasonService.getAnimeBySeason(call) } + getToJson(Routes.YEAR_INDEX) { seasonService.getYearsAndSeasons(call) } + } + route(Routes.DIRECTORY) { getToJson { service.getDirectory(call) } getToJson(Routes.TYPE) { directoryService.getAnimeByType(call) } - getToJson(Routes.SEASON) { directoryService.getAnimeBySeason(call) } } } \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/anime/services/AnimeService.kt b/src/main/kotlin/com/jeluchu/features/anime/services/AnimeService.kt index d105caa..95a5df2 100644 --- a/src/main/kotlin/com/jeluchu/features/anime/services/AnimeService.kt +++ b/src/main/kotlin/com/jeluchu/features/anime/services/AnimeService.kt @@ -2,6 +2,7 @@ package com.jeluchu.features.anime.services import com.jeluchu.core.connection.RestClient import com.jeluchu.core.enums.TimeUnit +import com.jeluchu.core.enums.parseAnimeStatusType import com.jeluchu.core.enums.parseAnimeType import com.jeluchu.core.extensions.needsUpdate import com.jeluchu.core.extensions.update @@ -19,7 +20,8 @@ import com.jeluchu.features.anime.mappers.documentToAnimeLastEpisodeEntity import com.jeluchu.features.anime.mappers.documentToAnimeTypeEntity import com.jeluchu.features.anime.mappers.documentToMoreInfoEntity import com.mongodb.client.MongoDatabase -import com.mongodb.client.model.Filters.eq +import com.mongodb.client.model.Filters +import com.mongodb.client.model.Sorts import io.ktor.http.* import io.ktor.server.response.* import io.ktor.server.routing.* @@ -63,7 +65,7 @@ class AnimeService( call.respond(HttpStatusCode.OK, Json.encodeToString(response)) } else { val animes = directoryCollection - .find(eq("type", type.uppercase())) + .find(Filters.eq("type", type.uppercase())) .skip(skipCount) .limit(size) .toList() @@ -84,7 +86,7 @@ class AnimeService( suspend fun getAnimeByMalId(call: RoutingCall) = try { val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException(ErrorMessages.InvalidMalId.message) - directoryCollection.find(eq("malId", id)).firstOrNull()?.let { anime -> + directoryCollection.find(Filters.eq("malId", id)).firstOrNull()?.let { anime -> val info = documentToMoreInfoEntity(anime) call.respond(HttpStatusCode.OK, Json.encodeToString(info)) } ?: call.respond(HttpStatusCode.NotFound, ErrorResponse(ErrorMessages.AnimeNotFound.message)) @@ -110,38 +112,42 @@ class AnimeService( if (needsUpdate) { collection.deleteMany(Document()) - val response = RestClient.request( - BaseUrls.JIKAN + "anime?status=airing&type=tv", - AnimeSearch.serializer() - ) - val animes = mutableListOf() - val totalPage = response.pagination?.lastPage ?: 0 - response.data?.map { it.toLastEpisodeData() }.orEmpty().let { animes.addAll(it) } - - for (page in 2..totalPage) { - val responsePage = RestClient.request( - BaseUrls.JIKAN + "anime?status=airing&type=tv&page=$page", - AnimeSearch.serializer() - ).data?.map { it.toLastEpisodeData() }.orEmpty() - animes.addAll(responsePage) - delay(1000) + RestClient.request( + BaseUrls.JIKAN + "anime?status=airing&type=tv&page=1", + AnimeSearch.serializer() + ).let { firstPage -> + val totalPage = firstPage.pagination?.lastPage ?: 2 + + firstPage.data?.let { firstAnimes -> + firstAnimes.map { it.toLastEpisodeData() }.let { animes.addAll(it) } + } + + for (page in 2..totalPage) { + RestClient.request( + BaseUrls.JIKAN + "anime?status=airing&type=tv&page=$page", + AnimeSearch.serializer() + ).data?.let { pagesAnimes -> + animes.addAll(pagesAnimes.map { it.toLastEpisodeData() }) + delay(1000) + } + } } - val documentsToInsert = parseDataToDocuments(animes, LastEpisodeEntity.serializer()) + val documentsToInsert = parseDataToDocuments(animes.distinctBy { it.malId }, LastEpisodeEntity.serializer()) if (documentsToInsert.isNotEmpty()) collection.insertMany(documentsToInsert) timers.update(timerKey) val queryDb = collection - .find(eq("day", dayOfWeek)) + .find(Filters.eq("day", dayOfWeek)) .toList() val elements = queryDb.map { documentToAnimeLastEpisodeEntity(it) } call.respond(HttpStatusCode.OK, Json.encodeToString(elements)) } else { val elements = collection - .find(eq("day", dayOfWeek)) + .find(Filters.eq("day", dayOfWeek)) .toList() .map { documentToAnimeLastEpisodeEntity(it) } @@ -150,4 +156,23 @@ class AnimeService( } catch (ex: Exception) { call.respond(HttpStatusCode.Unauthorized, ErrorResponse(ErrorMessages.UnauthorizedMongo.message)) } + + suspend fun getAnimeByType(call: RoutingCall) = try { + val type = call.request.queryParameters["type"] ?: throw IllegalArgumentException(ErrorMessages.InvalidTopAnimeType.message) + val status = call.request.queryParameters["status"] ?: throw IllegalArgumentException(ErrorMessages.InvalidAnimeStatusType.message) + + val animes = directoryCollection.find( + Filters.and( + Filters.eq("type", parseAnimeType(type)), + Filters.eq("status", parseAnimeStatusType(status)), + ) + ) + .sort(Sorts.descending("aired.from")) + .toList() + + val elements = animes.map { documentToAnimeTypeEntity(it) } + call.respond(HttpStatusCode.OK, Json.encodeToString(elements)) + } catch (ex: Exception) { + call.respond(HttpStatusCode.NotFound, ErrorResponse(ErrorMessages.InvalidInput.message)) + } } \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/anime/services/DirectoryService.kt b/src/main/kotlin/com/jeluchu/features/anime/services/DirectoryService.kt index eedb973..0d879f2 100644 --- a/src/main/kotlin/com/jeluchu/features/anime/services/DirectoryService.kt +++ b/src/main/kotlin/com/jeluchu/features/anime/services/DirectoryService.kt @@ -4,9 +4,10 @@ import com.jeluchu.core.enums.TimeUnit import com.jeluchu.core.enums.parseAnimeType import com.jeluchu.core.extensions.* import com.jeluchu.core.messages.ErrorMessages -import com.jeluchu.core.models.PaginationResponse import com.jeluchu.core.utils.Collections import com.jeluchu.core.utils.TimerKey +import com.jeluchu.core.utils.getLocalData +import com.jeluchu.core.utils.getRemoteData import com.jeluchu.features.anime.mappers.documentToAnimeDirectoryEntity import com.mongodb.client.MongoCollection import com.mongodb.client.MongoDatabase @@ -17,7 +18,6 @@ import io.ktor.server.routing.* import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.bson.Document -import org.bson.conversions.Bson class DirectoryService( private val database: MongoDatabase, @@ -58,83 +58,4 @@ class DirectoryService( onQuerySuccess = { data -> call.respond(HttpStatusCode.OK, Json.encodeToString(data)) } ) } - - suspend fun getAnimeBySeason(call: RoutingCall) { - val year = call.getIntSafeParam("year") - val season = call.getStringSafeParam("season") - val page = call.getIntSafeQueryParam("page", 1) - val size = call.getIntSafeQueryParam("size", 10) - - val skipCount = (page - 1) * size - val timerKey = "${TimerKey.ANIME_TYPE}${year}_${season.lowercase()}" - val collection = database.getCollection(timerKey) - if (page < 1 || size < 1) call.badRequestError(ErrorMessages.InvalidSizeAndPage.message) - - if (timers.needsUpdate(timerKey, 30, TimeUnit.DAY)) { - getRemoteData( - newCollection = collection, - remoteCollection = directory, - mapper = { documentToAnimeDirectoryEntity(it) }, - filters = Filters.and( - Filters.eq("year", year), - Filters.eq("season", season.lowercase()) - ), - onQuerySuccess = { data -> - val documents = data.map { Document.parse(Json.encodeToString(it)) } - if (documents.isNotEmpty()) collection.insertMany(documents) - timers.update(timerKey) - } - ) - } - - getLocalData( - page = page, - size = size, - skipCount = skipCount, - collection = collection, - mapper = { documentToAnimeDirectoryEntity(it) }, - onQuerySuccess = { data -> call.respond(HttpStatusCode.OK, Json.encodeToString(data)) } - ) - } -} - -private fun getRemoteData( - filters: Bson, - mapper: (Document) -> T, - onQuerySuccess: (List) -> Unit, - newCollection: MongoCollection, - remoteCollection: MongoCollection, -) { - newCollection.deleteMany(Document()) - - val query = remoteCollection - .find(filters) - .toList() - .map { mapper(it) } - - onQuerySuccess(query) -} - -private suspend fun getLocalData( - page: Int, - size: Int, - skipCount: Int, - mapper: (Document) -> T, - collection: MongoCollection, - onQuerySuccess: suspend (PaginationResponse) -> Unit -) { - val query = collection - .find() - .skip(skipCount) - .limit(size) - .toList() - .map { mapper(it) } - - val paginate = PaginationResponse( - page = page, - data = query, - size = query.size - ) - - onQuerySuccess(paginate) } \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/anime/services/SeasonService.kt b/src/main/kotlin/com/jeluchu/features/anime/services/SeasonService.kt new file mode 100644 index 0000000..73df943 --- /dev/null +++ b/src/main/kotlin/com/jeluchu/features/anime/services/SeasonService.kt @@ -0,0 +1,92 @@ +package com.jeluchu.features.anime.services + +import com.jeluchu.core.enums.TimeUnit +import com.jeluchu.core.enums.parseSeasons +import com.jeluchu.core.extensions.badRequestError +import com.jeluchu.core.extensions.getIntSafeQueryParam +import com.jeluchu.core.extensions.needsUpdate +import com.jeluchu.core.extensions.update +import com.jeluchu.core.messages.ErrorMessages +import com.jeluchu.core.utils.* +import com.jeluchu.features.anime.mappers.documentToAnimeDirectoryEntity +import com.jeluchu.features.anime.mappers.documentToSeasonEntity +import com.jeluchu.features.anime.models.seasons.YearSeasons +import com.mongodb.client.MongoCollection +import com.mongodb.client.MongoDatabase +import com.mongodb.client.model.Accumulators +import com.mongodb.client.model.Aggregates +import com.mongodb.client.model.Filters +import com.mongodb.client.model.Sorts +import io.ktor.http.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.bson.Document +import java.time.Year + +class SeasonService( + private val database: MongoDatabase, + private val directory: MongoCollection = database.getCollection(Collections.ANIME_DIRECTORY) +) { + suspend fun getAnimeBySeason(call: RoutingCall) { + val year = call.request.queryParameters["year"]?.toInt() ?: SeasonCalendar.currentYear + val station = parseSeasons(call.request.queryParameters["season"] ?: SeasonCalendar.currentSeason.name) ?: SeasonCalendar.currentSeason + val page = call.getIntSafeQueryParam("page", 1) + val size = call.getIntSafeQueryParam("size", 10) + + val skipCount = (page - 1) * size + if (page < 1 || size < 1) call.badRequestError(ErrorMessages.InvalidSizeAndPage.message) + + val query = directory.find( + Filters.and( + Filters.eq("season.year", year), + Filters.eq("season.station", station) + ) + ) + .toList() + .map { documentToSeasonEntity(it) } + + call.respond(HttpStatusCode.OK, Json.encodeToString(query)) + } + + suspend fun getYearsAndSeasons(call: RoutingCall) { + val currentYear = Year.now().value + val validSeasons = listOf("SUMMER", "FALL", "WINTER", "SPRING") + + val pipeline = listOf( + Aggregates.match( + Document("\$and", listOf( + Document("season.year", Document("\$gt", 0)), + Document("season.year", Document("\$lte", currentYear)), + Document("season.station", Document("\$in", validSeasons)) + )) + ), + + Aggregates.group( + "\$season.year", + Accumulators.addToSet("seasons", "\$season.station") + ), + + Aggregates.project( + Document().apply { + put("year", "\$_id") + put("seasons", 1) + put("_id", 0) + } + ), + + Aggregates.sort(Sorts.descending("year")) + ) + + val results = directory.aggregate(pipeline).toList() + val index = results.map { document -> + YearSeasons( + year = document.getInteger("year"), + seasons = document.getList("seasons", String::class.java) + ) + } + + call.respond(HttpStatusCode.OK, Json.encodeToString(index)) + } +} \ No newline at end of file