Compare commits

..

11 Commits

Author SHA1 Message Date
Jéluchu a113b7e677 Fixed cache control header 2025-07-07 17:27:41 +02:00
Jéluchu e57926050b Include headers to anti-cache in random endpoint 2025-07-07 14:40:53 +02:00
Jéluchu 05227eca80 Include random endpoint 2025-07-07 13:43:11 +02:00
Jéluchu 937d5c6ab3 Test with type endpoint a filter to nsfw content 2025-07-07 00:52:27 +02:00
Jéluchu e4334fe9bb Change model for return data in anime types 2025-07-06 23:36:01 +02:00
Jéluchu 6931ff7084 Fixed Simple Anime Entity and move tags to suggestion endpoint 2025-07-06 20:16:50 +02:00
Jéluchu b0fd2e84ad Refactor season endpoint and include tags endpoint 2025-07-06 19:15:38 +02:00
Jéluchu 03842ab1f9 Improves and include new endpoints
New enpoints for seasons and for types
2025-07-06 18:17:51 +02:00
Jéluchu e40894d0e0 Fixed animes duplicates in schedule 2025-07-04 17:13:07 +02:00
Jéluchu 5552904983 Fixed schedule endpoint 2025-07-04 14:38:19 +02:00
Jéluchu 1566c84e8b Include Top Ten endpoint 2025-07-04 12:37:16 +02:00
17 changed files with 476 additions and 152 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,38 @@
package com.jeluchu.core.models
import com.jeluchu.core.extensions.getDocumentSafe
import com.jeluchu.core.extensions.getIntSafe
import com.jeluchu.core.extensions.getStringSafe
import com.jeluchu.core.utils.SeasonCalendar
import kotlinx.serialization.Serializable
import org.bson.Document
@Serializable
data class SimpleAnimeEntity(
val malId: Int,
val type: String,
val title: String,
val image: String,
val score: String,
val season: SeasonInfo
) {
@Serializable
data class SeasonInfo(
val year: Int? = null,
val station: String? = null
)
}
fun documentToSimpleAnimeEntity(doc: Document) = SimpleAnimeEntity(
malId = doc.getIntSafe("malId"),
title = doc.getStringSafe("title"),
type = doc.getStringSafe("type"),
score = doc.getStringSafe("score"),
image = doc.getStringSafe("poster"),
season = doc.getDocumentSafe("season")?.let { documentToSeasonInfo(it) } ?: SimpleAnimeEntity.SeasonInfo(),
)
fun documentToSeasonInfo(doc: Document) = SimpleAnimeEntity.SeasonInfo(
year = doc.getIntSafe("year"),
station = doc.getStringSafe("station")
)

View File

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

View File

@ -52,11 +52,13 @@ object Routes {
const val ES = "/es" const val ES = "/es"
const val EN = "/en" const val EN = "/en"
const val TOP = "/top" const val TOP = "/top"
const val TAGS = "/tags"
const val NEWS = "/news" const val NEWS = "/news"
const val ANIME = "/anime" const val ANIME = "/anime"
const val MANGA = "/manga" const val MANGA = "/manga"
const val PEOPLE = "/people" const val PEOPLE = "/people"
const val SEARCH = "/search" const val SEARCH = "/search"
const val TOP_TEN = "/topTen"
const val GALLERY = "/gallery" const val GALLERY = "/gallery"
const val SCHEDULE = "/schedule" const val SCHEDULE = "/schedule"
const val RADIO_STATIONS = "/radio" const val RADIO_STATIONS = "/radio"
@ -68,9 +70,13 @@ 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 SUGGESTIONS = "/suggestions"
const val YEAR_INDEX = "/yearIndex"
const val RANDOM = "/random"
} }
object TimerKey { object TimerKey {
@ -86,6 +92,7 @@ object TimerKey {
object Collections { object Collections {
const val TIMERS = "timers" const val TIMERS = "timers"
const val TOP_TEN = "top_ten"
const val NEWS_ES = "news_es" const val NEWS_ES = "news_es"
const val NEWS_EN = "news_en" const val NEWS_EN = "news_en"
const val SCHEDULES = "schedule" const val SCHEDULES = "schedule"
@ -100,5 +107,6 @@ object Collections {
const val ANIME_DIRECTORY = "anime_directory" const val ANIME_DIRECTORY = "anime_directory"
const val CHARACTER_RANKING = "character_ranking" const val CHARACTER_RANKING = "character_ranking"
const val ANIME_PICTURES_QUERY = "anime_pictures_query" const val ANIME_PICTURES_QUERY = "anime_pictures_query"
const val ANIME_RANKING_TOP_TEN = "anime_ranking_top_ten"
const val ANIME_PICTURES_RECENT = "anime_pictures_recent" const val ANIME_PICTURES_RECENT = "anime_pictures_recent"
} }

View File

@ -1,44 +1,9 @@
package com.jeluchu.core.utils package com.jeluchu.core.utils
import com.jeluchu.features.rankings.models.AnimeTopEntity
import com.jeluchu.features.schedule.models.DayEntity
import com.jeluchu.features.schedule.models.ScheduleData
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.bson.Document import org.bson.Document
fun parseTopDataToDocuments(data: ScheduleData): List<Document> {
val documents = mutableListOf<Document>()
fun processDay(dayList: List<DayEntity>?) {
dayList?.forEach { animeData ->
val animeJsonString = Json.encodeToString(animeData)
val document = Document.parse(animeJsonString)
documents.add(document)
}
}
processDay(data.monday)
processDay(data.tuesday)
processDay(data.wednesday)
processDay(data.thursday)
processDay(data.friday)
processDay(data.saturday)
processDay(data.sunday)
return documents
}
fun parseTopDataToDocuments(data: List<AnimeTopEntity>?): List<Document> {
val documents = mutableListOf<Document>()
data?.forEach { animeData ->
val animeJsonString = Json.encodeToString(animeData)
val document = Document.parse(animeJsonString)
documents.add(document)
}
return documents
}
fun <T> parseDataToDocuments(data: List<T>?, serializer: KSerializer<T>): List<Document> { fun <T> parseDataToDocuments(data: List<T>?, serializer: KSerializer<T>): List<Document> {
val documents = mutableListOf<Document>() val documents = mutableListOf<Document>()
data?.forEach { item -> data?.forEach { item ->

View File

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

View File

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

View File

@ -4,20 +4,34 @@ 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.jeluchu.features.anime.services.TagsService
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),
tagsService: TagsService = TagsService(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.RANDOM) { service.getRandomAnime(call) }
getToJson(Routes.LAST_EPISODES) { service.getLastEpisodes(call) } getToJson(Routes.LAST_EPISODES) { service.getLastEpisodes(call) }
route(Routes.SUGGESTIONS) {
getToJson { tagsService.getAnimeByAnyTag(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) }
} }
} }

View File

@ -1,13 +1,17 @@
package com.jeluchu.features.anime.services package com.jeluchu.features.anime.services
import com.jeluchu.core.connection.RestClient import com.jeluchu.core.connection.RestClient
import com.jeluchu.core.enums.AnimeStatusTypes
import com.jeluchu.core.enums.AnimeTypes
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
import com.jeluchu.core.messages.ErrorMessages import com.jeluchu.core.messages.ErrorMessages
import com.jeluchu.core.models.ErrorResponse import com.jeluchu.core.models.ErrorResponse
import com.jeluchu.core.models.PaginationResponse import com.jeluchu.core.models.PaginationResponse
import com.jeluchu.core.models.documentToSimpleAnimeEntity
import com.jeluchu.features.anime.models.lastepisodes.LastEpisodeEntity import com.jeluchu.features.anime.models.lastepisodes.LastEpisodeEntity
import com.jeluchu.features.anime.models.lastepisodes.LastEpisodeEntity.Companion.toLastEpisodeData import com.jeluchu.features.anime.models.lastepisodes.LastEpisodeEntity.Companion.toLastEpisodeData
import com.jeluchu.core.models.jikan.search.AnimeSearch import com.jeluchu.core.models.jikan.search.AnimeSearch
@ -19,7 +23,9 @@ 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.Aggregates
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 +69,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 +90,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))
@ -92,6 +98,42 @@ class AnimeService(
call.respond(HttpStatusCode.NotFound, ErrorResponse(ErrorMessages.InvalidInput.message)) call.respond(HttpStatusCode.NotFound, ErrorResponse(ErrorMessages.InvalidInput.message))
} }
suspend fun getRandomAnime(call: RoutingCall) = try {
val nsfw = call.request.queryParameters["nsfw"]?.toBoolean() ?: false
val filters = mutableListOf<org.bson.conversions.Bson>().apply {
add(Filters.`in`("type", listOf(
AnimeTypes.TV,
AnimeTypes.MOVIE,
AnimeTypes.OVA,
AnimeTypes.SPECIAL,
AnimeTypes.ONA,
AnimeTypes.TV_SPECIAL
)))
add(Filters.nin("status", listOf(
AnimeStatusTypes.UPCOMING
)))
if (!nsfw) add(Filters.eq("nsfw", false))
}
val aggregates = listOf(
Aggregates.match(Filters.and(filters)),
Aggregates.sample(1)
)
directoryCollection.aggregate(aggregates).firstOrNull()?.let { anime ->
val info = documentToMoreInfoEntity(anime)
call.response.headers.append("Cache-Control", "no-store")
call.respond(HttpStatusCode.OK, Json.encodeToString(info))
} ?: call.respond(HttpStatusCode.NotFound, ErrorResponse(ErrorMessages.AnimeNotFound.message))
} catch (ex: Exception) {
call.respond(HttpStatusCode.NotFound, ErrorResponse(ErrorMessages.InvalidInput.message))
}
suspend fun getLastEpisodes(call: RoutingCall) = try { suspend fun getLastEpisodes(call: RoutingCall) = try {
val dayOfWeek = LocalDate.now() val dayOfWeek = LocalDate.now()
.dayOfWeek .dayOfWeek
@ -110,38 +152,42 @@ class AnimeService(
if (needsUpdate) { if (needsUpdate) {
collection.deleteMany(Document()) collection.deleteMany(Document())
val response = RestClient.request(
BaseUrls.JIKAN + "anime?status=airing&type=tv",
AnimeSearch.serializer()
)
val animes = mutableListOf<LastEpisodeEntity>() val animes = mutableListOf<LastEpisodeEntity>()
val totalPage = response.pagination?.lastPage ?: 0
response.data?.map { it.toLastEpisodeData() }.orEmpty().let { animes.addAll(it) }
for (page in 2..totalPage) { RestClient.request(
val responsePage = RestClient.request( BaseUrls.JIKAN + "anime?status=airing&type=tv&page=1",
BaseUrls.JIKAN + "anime?status=airing&type=tv&page=$page",
AnimeSearch.serializer() AnimeSearch.serializer()
).data?.map { it.toLastEpisodeData() }.orEmpty() ).let { firstPage ->
val totalPage = firstPage.pagination?.lastPage ?: 2
animes.addAll(responsePage) firstPage.data?.let { firstAnimes ->
delay(1000) firstAnimes.map { it.toLastEpisodeData() }.let { animes.addAll(it) }
} }
val documentsToInsert = parseDataToDocuments(animes, LastEpisodeEntity.serializer()) 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.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 +196,25 @@ 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 nsfw = call.request.queryParameters["nsfw"].toBoolean()
val animes = directoryCollection.find(
Filters.and(
Filters.eq("type", parseAnimeType(type)),
Filters.eq("status", parseAnimeStatusType(status)),
Filters.eq("nsfw", nsfw),
)
)
.sort(Sorts.descending("aired.from"))
.toList()
val elements = animes.map { documentToSimpleAnimeEntity(it) }
call.respond(HttpStatusCode.OK, Json.encodeToString(elements))
} catch (ex: Exception) {
call.respond(HttpStatusCode.NotFound, ErrorResponse(ErrorMessages.InvalidInput.message))
}
} }

View File

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

View File

@ -0,0 +1,83 @@
package com.jeluchu.features.anime.services
import com.jeluchu.core.enums.parseSeasons
import com.jeluchu.core.models.documentToSimpleAnimeEntity
import com.jeluchu.core.utils.Collections
import com.jeluchu.core.utils.SeasonCalendar
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["station"] ?: SeasonCalendar.currentSeason.name)
?: SeasonCalendar.currentSeason
val query = directory.find(
Filters.and(
Filters.eq("season.year", year),
Filters.eq("season.station", station),
Filters.ne("type", "MUSIC"),
Filters.ne("type", "PV"),
)
)
.toList()
.map { documentToSimpleAnimeEntity(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))
}
}

View File

@ -0,0 +1,55 @@
package com.jeluchu.features.anime.services
import com.jeluchu.core.enums.AnimeStatusTypes
import com.jeluchu.core.enums.AnimeTypes
import com.jeluchu.core.models.documentToSimpleAnimeEntity
import com.jeluchu.core.utils.Collections
import com.mongodb.client.MongoCollection
import com.mongodb.client.MongoDatabase
import com.mongodb.client.model.Filters
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
class TagsService(
private val database: MongoDatabase,
private val directory: MongoCollection<Document> = database.getCollection(Collections.ANIME_DIRECTORY)
) {
suspend fun getAnimeByAnyTag(call: RoutingCall) {
val tags = call.request.queryParameters["tags"].orEmpty()
val nsfw = call.request.queryParameters["nsfw"].toBoolean()
val tagsList = if (tags.isNotEmpty()) {
tags.split(",").map { it.trim() }
} else emptyList()
if (tagsList.isEmpty()) {
call.respond(HttpStatusCode.BadRequest, "No tags provided")
return
}
val filters = mutableListOf<org.bson.conversions.Bson>().apply {
add(Filters.or(
Filters.`in`("tags.es", tagsList),
Filters.`in`("tags.en", tagsList)
))
add(Filters.`in`("status", listOf(AnimeStatusTypes.FINISHED, AnimeStatusTypes.ONGOING)))
add(Filters.ne("type", AnimeTypes.MUSIC))
add(Filters.ne("type", AnimeTypes.PV))
add(Filters.ne("type", AnimeTypes.CM))
if (!nsfw) add(Filters.eq("nsfw", false))
}
val query = directory.find(Filters.and(filters))
.toList()
.map { documentToSimpleAnimeEntity(it) }
call.respond(HttpStatusCode.OK, Json.encodeToString(query))
}
}

View File

@ -12,6 +12,10 @@ fun Route.rankingsEndpoints(
) = route(Routes.TOP) { ) = route(Routes.TOP) {
route(Routes.ANIME) { route(Routes.ANIME) {
getToJson { service.getAnimeRanking(call) } getToJson { service.getAnimeRanking(call) }
route(Routes.TOP_TEN) {
getToJson { service.getAnimeTopTenRanking(call) }
}
} }
route(Routes.MANGA) { route(Routes.MANGA) {
getToJson { service.getMangaRanking(call) } getToJson { service.getMangaRanking(call) }

View File

@ -43,6 +43,7 @@ class RankingsService(
private val mangaRanking = database.getCollection(Collections.MANGA_RANKING) private val mangaRanking = database.getCollection(Collections.MANGA_RANKING)
private val peopleRanking = database.getCollection(Collections.PEOPLE_RANKING) private val peopleRanking = database.getCollection(Collections.PEOPLE_RANKING)
private val characterRanking = database.getCollection(Collections.CHARACTER_RANKING) private val characterRanking = database.getCollection(Collections.CHARACTER_RANKING)
private val animeRankingTopTen = database.getCollection(Collections.ANIME_RANKING_TOP_TEN)
suspend fun getAnimeRanking(call: RoutingCall) { suspend fun getAnimeRanking(call: RoutingCall) {
val filter = call.request.queryParameters["filter"] ?: "airing" val filter = call.request.queryParameters["filter"] ?: "airing"
@ -327,4 +328,65 @@ class RankingsService(
call.respond(HttpStatusCode.OK, Json.encodeToString(response)) call.respond(HttpStatusCode.OK, Json.encodeToString(response))
} }
} }
suspend fun getAnimeTopTenRanking(call: RoutingCall) {
val filter = call.request.queryParameters["filter"] ?: "airing"
val type = call.parameters["type"] ?: throw IllegalArgumentException(ErrorMessages.InvalidTopAnimeType.message)
if (parseAnimeType(type) == null) call.respond(HttpStatusCode.BadRequest, ErrorResponse(ErrorMessages.InvalidTopAnimeType.message))
val timerKey = "${Collections.ANIME_RANKING}_${Collections.TOP_TEN}_${type}_${filter}"
val needsUpdate = timers.needsUpdate(
amount = 7,
key = timerKey,
unit = TimeUnit.DAY
)
if (needsUpdate) {
animeRankingTopTen.deleteMany(
Filters.and(
Filters.eq("type", type),
Filters.eq("subtype", filter)
)
)
val params = mutableListOf<String>()
params.add("type=$type")
params.add("filter=$filter")
val response = RestClient.request(
BaseUrls.JIKAN + Endpoints.TOP_ANIME + "?${params.joinToString("&")}",
AnimeSearch.serializer()
).data?.map { anime ->
anime.toAnimeTopEntity(
page = 0,
top = "anime",
type = type,
subType = filter
)
}.orEmpty().take(11).distinctBy { it.malId }
val documentsToInsert = parseDataToDocuments(response, AnimeTopEntity.serializer())
if (documentsToInsert.isNotEmpty()) animeRankingTopTen.insertMany(documentsToInsert)
timers.update(timerKey)
val elements = documentsToInsert.map { documentToAnimeTopEntity(it) }
call.respond(HttpStatusCode.OK, Json.encodeToString(elements))
} else {
val animes = animeRankingTopTen
.find(
Filters.and(
Filters.eq("type", type),
Filters.eq("subtype", filter)
)
)
.toList()
val elements = animes.map { documentToAnimeTopEntity(it) }
call.respond(HttpStatusCode.OK, Json.encodeToString(elements))
}
}
} }

View File

@ -11,6 +11,7 @@ import com.jeluchu.core.models.ErrorResponse
import com.jeluchu.core.models.jikan.anime.AnimeData.Companion.toDayEntity import com.jeluchu.core.models.jikan.anime.AnimeData.Companion.toDayEntity
import com.jeluchu.core.utils.* import com.jeluchu.core.utils.*
import com.jeluchu.features.anime.mappers.documentToScheduleDayEntity import com.jeluchu.features.anime.mappers.documentToScheduleDayEntity
import com.jeluchu.features.schedule.models.DayEntity
import com.jeluchu.features.schedule.models.ScheduleData import com.jeluchu.features.schedule.models.ScheduleData
import com.jeluchu.features.schedule.models.ScheduleEntity import com.jeluchu.features.schedule.models.ScheduleEntity
import com.mongodb.client.MongoDatabase import com.mongodb.client.MongoDatabase
@ -37,22 +38,20 @@ class ScheduleService(
if (needsUpdate) { if (needsUpdate) {
schedules.deleteMany(Document()) schedules.deleteMany(Document())
val documents = mutableListOf<Document>()
val response = ScheduleData( Day.entries.forEach { day ->
sunday = getSchedule(Day.SUNDAY).data?.map { it.toDayEntity(Day.SUNDAY) }.orEmpty(), val animes = getSchedule(day).data?.map { it.toDayEntity(day) }.orEmpty()
friday = getSchedule(Day.FRIDAY).data?.map { it.toDayEntity(Day.FRIDAY) }.orEmpty(), val documentsToInsert = parseDataToDocuments(animes, DayEntity.serializer())
monday = getSchedule(Day.MONDAY).data?.map { it.toDayEntity(Day.MONDAY) }.orEmpty(), if (documentsToInsert.isNotEmpty()) {
tuesday = getSchedule(Day.TUESDAY).data?.map { it.toDayEntity(Day.TUESDAY) }.orEmpty(), documents.addAll(documentsToInsert)
thursday = getSchedule(Day.THURSDAY).data?.map { it.toDayEntity(Day.THURSDAY) }.orEmpty(), schedules.insertMany(documentsToInsert)
saturday = getSchedule(Day.SATURDAY).data?.map { it.toDayEntity(Day.SATURDAY) }.orEmpty(), }
wednesday = getSchedule(Day.WEDNESDAY).data?.map { it.toDayEntity(Day.WEDNESDAY) }.orEmpty() }
)
val elements = parseTopDataToDocuments(response)
if (elements.isNotEmpty()) schedules.insertMany(elements)
timers.update(TimerKey.SCHEDULE) timers.update(TimerKey.SCHEDULE)
call.respond(HttpStatusCode.OK, elements.documentWeekMapper()) call.respond(HttpStatusCode.OK, documents.documentWeekMapper())
} else { } else {
val elements = schedules.find().toList() val elements = schedules.find().toList()
call.respond(HttpStatusCode.OK, elements.documentWeekMapper()) call.respond(HttpStatusCode.OK, elements.documentWeekMapper())
@ -78,7 +77,16 @@ class ScheduleService(
) )
private fun List<Document>.documentWeekMapper(): String { private fun List<Document>.documentWeekMapper(): String {
val directory = map { documentToScheduleDayEntity(it) } val elements = map { documentToScheduleDayEntity(it) }
return Json.encodeToString(directory)
return Json.encodeToString(ScheduleData(
monday = elements.filter { it.day == Day.MONDAY.name.lowercase() }.distinctBy { it.malId },
tuesday = elements.filter { it.day == Day.TUESDAY.name.lowercase() }.distinctBy { it.malId },
wednesday = elements.filter { it.day == Day.WEDNESDAY.name.lowercase() }.distinctBy { it.malId },
thursday = elements.filter { it.day == Day.THURSDAY.name.lowercase() }.distinctBy { it.malId },
friday = elements.filter { it.day == Day.FRIDAY.name.lowercase() }.distinctBy { it.malId },
saturday = elements.filter { it.day == Day.SATURDAY.name.lowercase() }.distinctBy { it.malId },
sunday = elements.filter { it.day == Day.SUNDAY.name.lowercase() }.distinctBy { it.malId }
))
} }
} }