Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add total unseen minutes (missed episodes) #686

Merged
merged 1 commit into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package fr.shikkanime.controllers.admin

import com.google.inject.Inject
import fr.shikkanime.converters.AbstractConverter
import fr.shikkanime.dtos.TokenDto
import fr.shikkanime.dtos.member.TokenDto
import fr.shikkanime.entities.Anime
import fr.shikkanime.entities.EpisodeMapping
import fr.shikkanime.entities.EpisodeVariant
Expand Down
17 changes: 17 additions & 0 deletions src/main/kotlin/fr/shikkanime/controllers/api/MemberController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package fr.shikkanime.controllers.api
import com.google.inject.Inject
import fr.shikkanime.dtos.AllFollowedEpisodeDto
import fr.shikkanime.dtos.GenericDto
import fr.shikkanime.dtos.member.RefreshMemberDto
import fr.shikkanime.services.ImageService
import fr.shikkanime.services.MemberFollowAnimeService
import fr.shikkanime.services.MemberFollowEpisodeService
Expand All @@ -11,6 +12,7 @@ import fr.shikkanime.services.caches.MemberCacheService
import fr.shikkanime.utils.StringUtils
import fr.shikkanime.utils.routes.*
import fr.shikkanime.utils.routes.method.Delete
import fr.shikkanime.utils.routes.method.Get
import fr.shikkanime.utils.routes.method.Post
import fr.shikkanime.utils.routes.method.Put
import fr.shikkanime.utils.routes.openapi.OpenAPI
Expand Down Expand Up @@ -241,4 +243,19 @@ class MemberController {

return Response.ok()
}

@Path("/refresh")
@Get
@JWTAuthenticated
@OpenAPI(
description = "Get member data after a watchlist modification",
responses = [
OpenAPIResponse(200, "Member data refreshed", RefreshMemberDto::class),
OpenAPIResponse(401, "Unauthorized")
],
security = true
)
private fun getRefreshMember(@JWTUser memberUuid: UUID): Response {
return Response.ok(memberCacheService.getRefreshMember(memberUuid))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package fr.shikkanime.converters.member

import com.google.inject.Inject
import fr.shikkanime.converters.AbstractConverter
import fr.shikkanime.dtos.MemberDto
import fr.shikkanime.dtos.TokenDto
import fr.shikkanime.dtos.member.MemberDto
import fr.shikkanime.dtos.member.TokenDto
import fr.shikkanime.entities.Member
import fr.shikkanime.services.ImageService
import fr.shikkanime.services.MemberFollowAnimeService
Expand All @@ -19,6 +19,7 @@ class MemberToMemberDtoConverter : AbstractConverter<Member, MemberDto>() {

override fun convert(from: Member): MemberDto {
val tokenDto = convert(from, TokenDto::class.java)
val seenAndUnseenDuration = memberFollowEpisodeService.getSeenAndUnseenDuration(from)

return MemberDto(
uuid = from.uuid!!,
Expand All @@ -29,7 +30,8 @@ class MemberToMemberDtoConverter : AbstractConverter<Member, MemberDto>() {
email = from.email,
followedAnimes = memberFollowAnimeService.findAllFollowedAnimesUUID(from),
followedEpisodes = memberFollowEpisodeService.findAllFollowedEpisodesUUID(from),
totalDuration = memberFollowEpisodeService.getTotalDuration(from),
totalDuration = seenAndUnseenDuration.first,
totalUnseenDuration = seenAndUnseenDuration.second,
hasProfilePicture = ImageService[from.uuid, ImageService.Type.IMAGE] != null
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package fr.shikkanime.converters.member

import com.google.inject.Inject
import fr.shikkanime.converters.AbstractConverter
import fr.shikkanime.dtos.AnimeDto
import fr.shikkanime.dtos.MissedAnimeDto
import fr.shikkanime.dtos.PageableDto
import fr.shikkanime.dtos.mappings.EpisodeMappingDto
import fr.shikkanime.dtos.member.RefreshMemberDto
import fr.shikkanime.entities.Member
import fr.shikkanime.services.MemberFollowAnimeService
import fr.shikkanime.services.MemberFollowEpisodeService

class MemberToRefreshMemberDtoConverter : AbstractConverter<Member, RefreshMemberDto>() {
@Inject
private lateinit var memberFollowAnimeService: MemberFollowAnimeService

@Inject
private lateinit var memberFollowEpisodeService: MemberFollowEpisodeService

override fun convert(from: Member): RefreshMemberDto {
val page = 1
val limit = 9

val missedAnimesPageable = memberFollowAnimeService.findAllMissedAnimes(from, page, limit)
val followedAnimesPageable = memberFollowAnimeService.findAllFollowedAnimes(from, page, limit)
val followedEpisodesPageable = memberFollowEpisodeService.findAllFollowedEpisodes(from, page, limit)
val (totalDuration, totalUnseenDuration) = memberFollowEpisodeService.getSeenAndUnseenDuration(from)

val missedAnimeDtos = missedAnimesPageable.data.map { tuple ->
MissedAnimeDto(
convert(tuple[0], AnimeDto::class.java),
tuple[1] as Long
)
}

return RefreshMemberDto(
missedAnimes = PageableDto(
data = missedAnimeDtos,
page = missedAnimesPageable.page,
limit = missedAnimesPageable.limit,
total = missedAnimesPageable.total,
),
followedAnimes = PageableDto.fromPageable(followedAnimesPageable, AnimeDto::class.java),
followedEpisodes = PageableDto.fromPageable(followedEpisodesPageable, EpisodeMappingDto::class.java),
totalDuration = totalDuration,
totalUnseenDuration = totalUnseenDuration,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package fr.shikkanime.converters.member
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import fr.shikkanime.converters.AbstractConverter
import fr.shikkanime.dtos.TokenDto
import fr.shikkanime.dtos.member.TokenDto
import fr.shikkanime.entities.Member
import fr.shikkanime.utils.Constant
import java.util.*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package fr.shikkanime.dtos
package fr.shikkanime.dtos.member

import java.util.*

Expand All @@ -12,5 +12,6 @@ data class MemberDto(
val followedAnimes: List<UUID>,
val followedEpisodes: List<UUID>,
val totalDuration: Long,
val totalUnseenDuration: Long,
val hasProfilePicture: Boolean = false,
)
14 changes: 14 additions & 0 deletions src/main/kotlin/fr/shikkanime/dtos/member/RefreshMemberDto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package fr.shikkanime.dtos.member

import fr.shikkanime.dtos.AnimeDto
import fr.shikkanime.dtos.MissedAnimeDto
import fr.shikkanime.dtos.PageableDto
import fr.shikkanime.dtos.mappings.EpisodeMappingDto

data class RefreshMemberDto(
val missedAnimes: PageableDto<MissedAnimeDto>,
val followedAnimes: PageableDto<AnimeDto>,
val followedEpisodes: PageableDto<EpisodeMappingDto>,
val totalDuration: Long,
val totalUnseenDuration: Long,
)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package fr.shikkanime.dtos
package fr.shikkanime.dtos.member

import io.ktor.server.auth.*

Expand Down
1 change: 1 addition & 0 deletions src/main/kotlin/fr/shikkanime/modules/Routing.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package fr.shikkanime.modules

import fr.shikkanime.dtos.*
import fr.shikkanime.dtos.enums.Status
import fr.shikkanime.dtos.member.TokenDto
import fr.shikkanime.entities.enums.ConfigPropertyKey
import fr.shikkanime.entities.enums.CountryCode
import fr.shikkanime.entities.enums.LangType
Expand Down
2 changes: 1 addition & 1 deletion src/main/kotlin/fr/shikkanime/modules/Security.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import fr.shikkanime.dtos.MessageDto
import fr.shikkanime.dtos.TokenDto
import fr.shikkanime.dtos.member.TokenDto
import fr.shikkanime.entities.enums.Role
import fr.shikkanime.services.caches.MemberCacheService
import fr.shikkanime.utils.Constant
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package fr.shikkanime.repositories

import fr.shikkanime.entities.*
import fr.shikkanime.entities.enums.EpisodeType
import jakarta.persistence.criteria.JoinType
import java.util.*

class MemberFollowEpisodeRepository : AbstractRepository<MemberFollowEpisode>() {
Expand Down Expand Up @@ -112,20 +114,44 @@ class MemberFollowEpisodeRepository : AbstractRepository<MemberFollowEpisode>()
}
}

fun getTotalDuration(member: Member): Long {
fun getSeenAndUnseenDuration(member: Member): Pair<Long, Long> {
return database.entityManager.use {
val cb = it.criteriaBuilder
val query = cb.createQuery(Long::class.java)
val root = query.from(getEntityClass())
query.select(cb.sum(root[MemberFollowEpisode_.episode][EpisodeMapping_.duration]))
val query = cb.createTupleQuery()
val root = query.from(MemberFollowAnime::class.java)
val anime = root.join(MemberFollowAnime_.anime)
val episodeMapping = anime.join(Anime_.mappings, JoinType.LEFT)
val memberFollowEpisode = episodeMapping.join(EpisodeMapping_.memberFollowEpisodes, JoinType.LEFT)
memberFollowEpisode.on(cb.equal(memberFollowEpisode[MemberFollowEpisode_.member], member))

val seenDuration = cb.sum(
cb.selectCase<Number?>()
.`when`(cb.isNotNull(memberFollowEpisode[MemberFollowEpisode_.episode]), episodeMapping[EpisodeMapping_.duration])
.otherwise(0)
)

query.where(
cb.equal(root[MemberFollowEpisode_.member], member)
val unseenDuration = cb.sum(
cb.selectCase<Number?>()
.`when`(
cb.and(
cb.isNull(memberFollowEpisode[MemberFollowEpisode_.episode]),
cb.notEqual(episodeMapping[EpisodeMapping_.episodeType], EpisodeType.SUMMARY)
),
episodeMapping[EpisodeMapping_.duration]
)
.otherwise(0)
)

it.createQuery(query)
.resultList
.firstOrNull() ?: 0L
query.multiselect(
cb.coalesce(seenDuration, 0L),
cb.coalesce(unseenDuration, 0L)
)

query.where(cb.equal(root[MemberFollowAnime_.member], member))

createReadOnlyQuery(it, query)
.singleResult
.let { pair -> pair[0] as Long to pair[1] as Long }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class MemberFollowEpisodeService : AbstractService<MemberFollowEpisode, MemberFo
fun findAllByEpisode(episodeMapping: EpisodeMapping) =
memberFollowEpisodeRepository.findAllByEpisode(episodeMapping)

fun getTotalDuration(member: Member) = memberFollowEpisodeRepository.getTotalDuration(member)
fun getSeenAndUnseenDuration(member: Member) = memberFollowEpisodeRepository.getSeenAndUnseenDuration(member)

fun followAll(memberUuid: UUID, anime: GenericDto): Response {
val member = memberService.find(memberUuid) ?: return Response.notFound()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ package fr.shikkanime.services.caches

import com.google.inject.Inject
import fr.shikkanime.converters.AbstractConverter
import fr.shikkanime.dtos.MemberDto
import fr.shikkanime.dtos.member.MemberDto
import fr.shikkanime.dtos.member.RefreshMemberDto
import fr.shikkanime.entities.*
import fr.shikkanime.services.MemberService
import fr.shikkanime.utils.MapCache
Expand Down Expand Up @@ -32,7 +33,21 @@ class MemberCacheService : AbstractCacheService {
?.let { member -> AbstractConverter.convert(member, MemberDto::class.java) }
}

private val refreshMemberCache = MapCache<UUID, RefreshMemberDto?>(
classes = listOf(
Member::class.java,
Anime::class.java,
MemberFollowAnime::class.java,
EpisodeMapping::class.java,
MemberFollowEpisode::class.java,
)
) {
memberService.find(it)?.let { member -> AbstractConverter.convert(member, RefreshMemberDto::class.java) }
}

fun find(uuid: UUID) = cache[uuid]

fun findByIdentifier(identifier: String) = findByIdentifierCache[identifier]

fun getRefreshMember(uuid: UUID) = refreshMemberCache[uuid]
}
2 changes: 1 addition & 1 deletion src/main/kotlin/fr/shikkanime/utils/routes/Response.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package fr.shikkanime.utils.routes

import fr.shikkanime.dtos.TokenDto
import fr.shikkanime.dtos.member.TokenDto
import fr.shikkanime.entities.enums.Link
import io.ktor.http.*

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package fr.shikkanime.controllers.api

import com.google.inject.Inject
import fr.shikkanime.dtos.MemberDto
import fr.shikkanime.dtos.member.MemberDto
import fr.shikkanime.entities.*
import fr.shikkanime.entities.enums.CountryCode
import fr.shikkanime.entities.enums.EpisodeType
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package fr.shikkanime.controllers.api

import fr.shikkanime.dtos.GenericDto
import fr.shikkanime.dtos.MemberDto
import fr.shikkanime.dtos.member.MemberDto
import fr.shikkanime.dtos.member.RefreshMemberDto
import fr.shikkanime.entities.Member
import fr.shikkanime.entities.MemberAction
import fr.shikkanime.entities.enums.Action
Expand Down Expand Up @@ -449,4 +450,57 @@ class MemberControllerTest : AbstractControllerTest() {
}
}
}

@Test
fun refreshMember() {
testApplication {
application {
module()
}

val (identifier, token) = registerAndLogin()
val anime = animeService.findAll().first()

client.put("/api/v1/members/animes") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(ObjectParser.toJson(GenericDto(anime.uuid!!)))
}.apply {
assertEquals(HttpStatusCode.OK, status)
val findPrivateMember = memberService.findByIdentifier(identifier)
val followedAnimesUUID = memberFollowAnimeService.findAllFollowedAnimesUUID(findPrivateMember!!)
assertNotNull(findPrivateMember)
assertEquals(1, followedAnimesUUID.size)
assertEquals(anime.uuid, followedAnimesUUID.first())
}

client.put("/api/v1/members/follow-all-episodes") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(ObjectParser.toJson(GenericDto(anime.uuid!!)))
}.apply {
assertEquals(HttpStatusCode.OK, status)
val findPrivateMember = memberService.findByIdentifier(identifier)
val followedEpisodes = memberFollowEpisodeService.findAllFollowedEpisodesUUID(findPrivateMember!!)
assertNotNull(findPrivateMember)
assertEquals(116, followedEpisodes.size)
}

client.get("/api/v1/members/refresh") {
header(HttpHeaders.Authorization, "Bearer $token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
}.apply {
assertEquals(HttpStatusCode.OK, status)
val refreshMemberDto = ObjectParser.fromJson(bodyAsText(), RefreshMemberDto::class.java)
println(refreshMemberDto)
val findPrivateMember = memberService.findByIdentifier(identifier)
val followedEpisodes = memberFollowEpisodeService.findAllFollowedEpisodesUUID(findPrivateMember!!)
assertNotNull(findPrivateMember)
assertEquals(1, refreshMemberDto.followedAnimes.total)
assertEquals(followedEpisodes.size.toLong(), refreshMemberDto.followedEpisodes.total)
assertTrue(refreshMemberDto.totalDuration > 0)
assertEquals(0, refreshMemberDto.totalUnseenDuration)
}
}
}
}