Improves and include new endpoints

New enpoints for seasons and for types
v5
Jéluchu 1 month ago
parent e40894d0e0
commit 03842ab1f9

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

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

@ -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 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 InvalidSizeAndPage : ErrorMessages("Invalid page and size parameters")
data object InvalidTopAnimeType : ErrorMessages("Invalid 'type' parameter. Valid values are: $animeTypesErrorList") 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 InvalidTopAnimeFilterType : ErrorMessages("Invalid 'type' parameter. Valid values are: $animeFilterTypesErrorList")
data object InvalidTopMangaType : ErrorMessages("Invalid 'type' parameter. Valid values are: $mangaTypesErrorList") data object InvalidTopMangaType : ErrorMessages("Invalid 'type' parameter. Valid values are: $mangaTypesErrorList")
data object InvalidTopMangaFilterType : ErrorMessages("Invalid 'type' parameter. Valid values are: $mangaFilterTypesErrorList") data object InvalidTopMangaFilterType : ErrorMessages("Invalid 'type' parameter. Valid values are: $mangaFilterTypesErrorList")

@ -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 <T> getRemoteData(
filters: Bson,
mapper: (Document) -> T,
onQuerySuccess: (List<T>) -> Unit,
newCollection: MongoCollection<Document>,
remoteCollection: MongoCollection<Document>,
) {
newCollection.deleteMany(Document())
val query = remoteCollection
.find(filters)
.toList()
.map { mapper(it) }
onQuerySuccess(query)
}
suspend fun <T> getLocalData(
page: Int,
size: Int,
skipCount: Int,
mapper: (Document) -> T,
collection: MongoCollection<Document>,
onQuerySuccess: suspend (PaginationResponse<T>) -> 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)
}

@ -69,9 +69,11 @@ object Routes {
const val EPISODES = "/episodes" const val EPISODES = "/episodes"
const val ID = "/{id}" const val ID = "/{id}"
const val TYPE = "/{type}" 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 DAY = "/{day}"
const val THEMES = "/themes" const val THEMES = "/themes"
const val YEAR_INDEX = "/yearIndex"
} }
object TimerKey { object TimerKey {

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

@ -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")
)

@ -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,
)

@ -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<String>
)

@ -4,20 +4,27 @@ import com.jeluchu.core.extensions.getToJson
import com.jeluchu.core.utils.Routes import com.jeluchu.core.utils.Routes
import com.jeluchu.features.anime.services.AnimeService import com.jeluchu.features.anime.services.AnimeService
import com.jeluchu.features.anime.services.DirectoryService import com.jeluchu.features.anime.services.DirectoryService
import com.jeluchu.features.anime.services.SeasonService
import com.mongodb.client.MongoDatabase import com.mongodb.client.MongoDatabase
import io.ktor.server.routing.* import io.ktor.server.routing.*
fun Route.animeEndpoints( fun Route.animeEndpoints(
mongoDatabase: MongoDatabase, mongoDatabase: MongoDatabase,
service: AnimeService = AnimeService(mongoDatabase), service: AnimeService = AnimeService(mongoDatabase),
seasonService: SeasonService = SeasonService(mongoDatabase),
directoryService: DirectoryService = DirectoryService(mongoDatabase), directoryService: DirectoryService = DirectoryService(mongoDatabase),
) = route(Routes.ANIME) { ) = route(Routes.ANIME) {
getToJson { service.getAnimeByType(call) }
getToJson(Routes.ID) { service.getAnimeByMalId(call) } getToJson(Routes.ID) { service.getAnimeByMalId(call) }
getToJson(Routes.LAST_EPISODES) { service.getLastEpisodes(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) { route(Routes.DIRECTORY) {
getToJson { service.getDirectory(call) } getToJson { service.getDirectory(call) }
getToJson(Routes.TYPE) { directoryService.getAnimeByType(call) } getToJson(Routes.TYPE) { directoryService.getAnimeByType(call) }
getToJson(Routes.SEASON) { directoryService.getAnimeBySeason(call) }
} }
} }

@ -2,6 +2,7 @@ package com.jeluchu.features.anime.services
import com.jeluchu.core.connection.RestClient import com.jeluchu.core.connection.RestClient
import com.jeluchu.core.enums.TimeUnit import com.jeluchu.core.enums.TimeUnit
import com.jeluchu.core.enums.parseAnimeStatusType
import com.jeluchu.core.enums.parseAnimeType import com.jeluchu.core.enums.parseAnimeType
import com.jeluchu.core.extensions.needsUpdate import com.jeluchu.core.extensions.needsUpdate
import com.jeluchu.core.extensions.update 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.documentToAnimeTypeEntity
import com.jeluchu.features.anime.mappers.documentToMoreInfoEntity import com.jeluchu.features.anime.mappers.documentToMoreInfoEntity
import com.mongodb.client.MongoDatabase 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.http.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
@ -63,7 +65,7 @@ class AnimeService(
call.respond(HttpStatusCode.OK, Json.encodeToString(response)) call.respond(HttpStatusCode.OK, Json.encodeToString(response))
} else { } else {
val animes = directoryCollection val animes = directoryCollection
.find(eq("type", type.uppercase())) .find(Filters.eq("type", type.uppercase()))
.skip(skipCount) .skip(skipCount)
.limit(size) .limit(size)
.toList() .toList()
@ -84,7 +86,7 @@ class AnimeService(
suspend fun getAnimeByMalId(call: RoutingCall) = try { suspend fun getAnimeByMalId(call: RoutingCall) = try {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException(ErrorMessages.InvalidMalId.message) 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) val info = documentToMoreInfoEntity(anime)
call.respond(HttpStatusCode.OK, Json.encodeToString(info)) call.respond(HttpStatusCode.OK, Json.encodeToString(info))
} ?: call.respond(HttpStatusCode.NotFound, ErrorResponse(ErrorMessages.AnimeNotFound.message)) } ?: call.respond(HttpStatusCode.NotFound, ErrorResponse(ErrorMessages.AnimeNotFound.message))
@ -110,38 +112,42 @@ class AnimeService(
if (needsUpdate) { if (needsUpdate) {
collection.deleteMany(Document()) collection.deleteMany(Document())
val response = RestClient.request( val animes = mutableListOf<LastEpisodeEntity>()
BaseUrls.JIKAN + "anime?status=airing&type=tv",
RestClient.request(
BaseUrls.JIKAN + "anime?status=airing&type=tv&page=1",
AnimeSearch.serializer() AnimeSearch.serializer()
) ).let { firstPage ->
val totalPage = firstPage.pagination?.lastPage ?: 2
val animes = mutableListOf<LastEpisodeEntity>() firstPage.data?.let { firstAnimes ->
val totalPage = response.pagination?.lastPage ?: 0 firstAnimes.map { it.toLastEpisodeData() }.let { animes.addAll(it) }
response.data?.map { it.toLastEpisodeData() }.orEmpty().let { animes.addAll(it) } }
for (page in 2..totalPage) { for (page in 2..totalPage) {
val responsePage = RestClient.request( RestClient.request(
BaseUrls.JIKAN + "anime?status=airing&type=tv&page=$page", BaseUrls.JIKAN + "anime?status=airing&type=tv&page=$page",
AnimeSearch.serializer() AnimeSearch.serializer()
).data?.map { it.toLastEpisodeData() }.orEmpty() ).data?.let { pagesAnimes ->
animes.addAll(pagesAnimes.map { it.toLastEpisodeData() })
animes.addAll(responsePage)
delay(1000) delay(1000)
} }
}
}
val documentsToInsert = parseDataToDocuments(animes, LastEpisodeEntity.serializer()) val documentsToInsert = parseDataToDocuments(animes.distinctBy { it.malId }, LastEpisodeEntity.serializer())
if (documentsToInsert.isNotEmpty()) collection.insertMany(documentsToInsert) if (documentsToInsert.isNotEmpty()) collection.insertMany(documentsToInsert)
timers.update(timerKey) timers.update(timerKey)
val queryDb = collection val queryDb = collection
.find(eq("day", dayOfWeek)) .find(Filters.eq("day", dayOfWeek))
.toList() .toList()
val elements = queryDb.map { documentToAnimeLastEpisodeEntity(it) } val elements = queryDb.map { documentToAnimeLastEpisodeEntity(it) }
call.respond(HttpStatusCode.OK, Json.encodeToString(elements)) call.respond(HttpStatusCode.OK, Json.encodeToString(elements))
} else { } else {
val elements = collection val elements = collection
.find(eq("day", dayOfWeek)) .find(Filters.eq("day", dayOfWeek))
.toList() .toList()
.map { documentToAnimeLastEpisodeEntity(it) } .map { documentToAnimeLastEpisodeEntity(it) }
@ -150,4 +156,23 @@ class AnimeService(
} catch (ex: Exception) { } catch (ex: Exception) {
call.respond(HttpStatusCode.Unauthorized, ErrorResponse(ErrorMessages.UnauthorizedMongo.message)) 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))
}
} }

@ -4,9 +4,10 @@ import com.jeluchu.core.enums.TimeUnit
import com.jeluchu.core.enums.parseAnimeType import com.jeluchu.core.enums.parseAnimeType
import com.jeluchu.core.extensions.* import com.jeluchu.core.extensions.*
import com.jeluchu.core.messages.ErrorMessages import com.jeluchu.core.messages.ErrorMessages
import com.jeluchu.core.models.PaginationResponse
import com.jeluchu.core.utils.Collections import com.jeluchu.core.utils.Collections
import com.jeluchu.core.utils.TimerKey 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.jeluchu.features.anime.mappers.documentToAnimeDirectoryEntity
import com.mongodb.client.MongoCollection import com.mongodb.client.MongoCollection
import com.mongodb.client.MongoDatabase import com.mongodb.client.MongoDatabase
@ -17,7 +18,6 @@ import io.ktor.server.routing.*
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.bson.Document import org.bson.Document
import org.bson.conversions.Bson
class DirectoryService( class DirectoryService(
private val database: MongoDatabase, private val database: MongoDatabase,
@ -58,83 +58,4 @@ class DirectoryService(
onQuerySuccess = { data -> call.respond(HttpStatusCode.OK, Json.encodeToString(data)) } 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 <T> getRemoteData(
filters: Bson,
mapper: (Document) -> T,
onQuerySuccess: (List<T>) -> Unit,
newCollection: MongoCollection<Document>,
remoteCollection: MongoCollection<Document>,
) {
newCollection.deleteMany(Document())
val query = remoteCollection
.find(filters)
.toList()
.map { mapper(it) }
onQuerySuccess(query)
}
private suspend fun <T> getLocalData(
page: Int,
size: Int,
skipCount: Int,
mapper: (Document) -> T,
collection: MongoCollection<Document>,
onQuerySuccess: suspend (PaginationResponse<T>) -> 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)
} }

@ -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<Document> = 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))
}
}
Loading…
Cancel
Save