Skip to content
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
38 changes: 38 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/api/JikanApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.arflix.tv.data.api

import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import retrofit2.http.GET
import retrofit2.http.Path

/**
* Jikan v4 is an unofficial REST API for MyAnimeList.net data.
*
* Used by ARVIO to display the MAL community score next to IMDB/TMDB ratings
* on anime details pages. See issue #45.
*
* Base URL: `https://api.jikan.moe/v4/`
* Rate limit: ~3 req/s, 60 req/min (unofficial, subject to change). ARVIO
* caches scores in memory so a typical details load performs at most one
* Jikan request per unique MAL ID per session.
*
* Docs: https://docs.api.jikan.moe/
*/
interface JikanApi {
@GET("anime/{malId}")
suspend fun getAnime(@Path("malId") malId: Int): JikanAnimeResponse
}

@Keep
data class JikanAnimeResponse(
@SerializedName("data") val data: JikanAnimeData?
)

@Keep
data class JikanAnimeData(
@SerializedName("mal_id") val malId: Int?,
@SerializedName("title") val title: String?,
/** Community score 0-10 with 2 decimal places. Null if the entry is too new or unscored. */
@SerializedName("score") val score: Double?,
@SerializedName("scored_by") val scoredBy: Int?
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.arflix.tv.data.repository

import com.arflix.tv.data.api.ArmApi
import com.arflix.tv.data.api.JikanApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.util.Collections
import javax.inject.Inject
import javax.inject.Singleton

/**
* Resolves MyAnimeList community scores for anime titles.
*
* The flow is:
* IMDB id \u2192 ARM API (maps imdb -> mal_id) \u2192 Jikan v4 anime endpoint (returns score).
*
* Both hops are cached in-memory (per-process) for the session so repeated
* navigation to the same anime doesn't re-query either API. Jikan's unofficial
* rate limit is ~3 req/s, so caching is important if the user is browsing a
* catalog of many anime.
*
* All exceptions (network failures, null values, rate limits, JSON mismatches)
* are swallowed and return null \u2014 the caller hides the MAL badge on null.
*
* Issue #45.
*/
@Singleton
class AnimeScoreRepository @Inject constructor(
private val armApi: ArmApi,
private val jikanApi: JikanApi
) {
// Map<imdbId, malId?> \u2014 nulls cached to avoid re-hitting ARM for negative results.
private val malIdCache: MutableMap<String, Int?> =
Collections.synchronizedMap(LinkedHashMap())

// Map<malId, score?> \u2014 same semantics as above.
private val scoreCache: MutableMap<Int, Double?> =
Collections.synchronizedMap(LinkedHashMap())

/**
* Look up the MAL community score for an anime by its IMDB id.
* Returns the raw score (0.0-10.0) or null if not available / not an anime /
* any network error / API down.
*
* Safe to call for non-anime; the ARM lookup will just return null.
*/
suspend fun getMalScore(imdbId: String?): Double? = withContext(Dispatchers.IO) {
val trimmed = imdbId?.trim().orEmpty()
if (trimmed.isEmpty()) return@withContext null

val malId = resolveMalId(trimmed) ?: return@withContext null
resolveScore(malId)
}

private suspend fun resolveMalId(imdbId: String): Int? {
// Cache hit (including negative cache)
if (malIdCache.containsKey(imdbId)) return malIdCache[imdbId]

val resolved = withTimeoutOrNull(2_000L) {
runCatching { armApi.resolve(imdbId).firstOrNull()?.myanimelist }.getOrNull()
}
malIdCache[imdbId] = resolved
trimCache(malIdCache)
return resolved
}

private suspend fun resolveScore(malId: Int): Double? {
if (scoreCache.containsKey(malId)) return scoreCache[malId]

val score = withTimeoutOrNull(2_000L) {
runCatching { jikanApi.getAnime(malId).data?.score }.getOrNull()
}
scoreCache[malId] = score
trimCache(scoreCache)
return score
}

/** Crude LRU bound to keep per-process memory reasonable during long sessions. */
private fun <K, V> trimCache(cache: MutableMap<K, V>) {
val maxEntries = 256
if (cache.size <= maxEntries) return
synchronized(cache) {
val iterator = cache.entries.iterator()
var toRemove = cache.size - maxEntries
while (iterator.hasNext() && toRemove > 0) {
iterator.next()
iterator.remove()
toRemove--
}
}
}
}
17 changes: 17 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,21 @@ object AppModule {
fun provideArmApi(@Named("arm") retrofit: Retrofit): ArmApi {
return retrofit.create(ArmApi::class.java)
}

@Provides
@Singleton
@Named("jikan")
fun provideJikanRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.jikan.moe/v4/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}

@Provides
@Singleton
fun provideJikanApi(@Named("jikan") retrofit: Retrofit): com.arflix.tv.data.api.JikanApi {
return retrofit.create(com.arflix.tv.data.api.JikanApi::class.java)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@ fun DetailsScreen(
usePosterCards = usePosterCards,
isMobile = isMobile,
onBack = onBack,
malScore = uiState.malScore,
onButtonClick = { idx ->
when (idx) {
0 -> { // Play
Expand Down Expand Up @@ -939,6 +940,9 @@ private fun DetailsContent(
// Persistent back callback used by the phone-layout back button overlay
// (issue #43). No-op by default so tablet/TV callers don't need to pass it.
onBack: () -> Unit = {},
// MAL community score for anime (issue #45). Plumbed from DetailsUiState.malScore.
// Null for non-anime or when Jikan returns no score.
malScore: Double? = null,
onButtonClick: (Int) -> Unit = {},
onSeasonClick: (Int) -> Unit = {},
onEpisodeClick: (Int) -> Unit = {},
Expand Down Expand Up @@ -1101,6 +1105,30 @@ private fun DetailsContent(
)
}
}
// MyAnimeList community score badge for anime only. Populated
// asynchronously after details load via Jikan API. Hidden when
// the content isn't anime or Jikan returns null. Issue #45.
if (malScore != null && malScore > 0.0) {
Text(text = "|", style = ArflixTypography.caption.copy(fontSize = 12.sp), color = Color.White.copy(alpha = 0.4f))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier
.background(Color(0xFF2E51A2), RoundedCornerShape(3.dp))
.padding(horizontal = 5.dp, vertical = 1.dp)
) {
Text(
text = "MAL",
style = ArflixTypography.caption.copy(fontSize = 8.sp, fontWeight = FontWeight.Black),
color = Color.White
)
Text(
text = String.format("%.1f", malScore),
style = ArflixTypography.caption.copy(fontSize = 10.sp, fontWeight = FontWeight.Bold),
color = Color.White
)
}
}
}

Spacer(modifier = Modifier.height(10.dp))
Expand Down Expand Up @@ -1512,6 +1540,29 @@ private fun DetailsContent(
}
}

// MAL community score badge for anime. Issue #45.
if (malScore != null && malScore > 0.0) {
Text(text = "|", style = separatorStyle, color = Color.White.copy(alpha = 0.7f))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.background(Color(0xFF2E51A2), RoundedCornerShape(3.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(
text = "MAL",
style = ArflixTypography.caption.copy(fontSize = 9.sp, fontWeight = FontWeight.Black),
color = Color.White
)
Text(
text = String.format("%.1f", malScore),
style = ArflixTypography.caption.copy(fontSize = 11.sp, fontWeight = FontWeight.Bold),
color = Color.White
)
}
}

if (!budgetText.isNullOrBlank()) {
Text(text = "|", style = separatorStyle, color = Color.White.copy(alpha = 0.7f))
Text(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.arflix.tv.data.model.Review
import com.arflix.tv.data.model.StreamSource
import com.arflix.tv.data.model.Subtitle
import com.arflix.tv.data.api.TmdbApi
import com.arflix.tv.data.repository.AnimeScoreRepository
import com.arflix.tv.data.repository.CloudSyncRepository
import com.arflix.tv.data.repository.LauncherContinueWatchingRepository
import com.arflix.tv.data.repository.MediaRepository
Expand Down Expand Up @@ -83,7 +84,10 @@ data class DetailsUiState(
val playLabel: String? = null,
val playPositionMs: Long? = null,
val autoPlaySingleSource: Boolean = true,
val autoPlayMinQuality: String = "Any"
val autoPlayMinQuality: String = "Any",
// MyAnimeList community score for anime (0.0-10.0). Null for non-anime or when
// the Jikan / ARM lookup fails or the entry has no community score yet. Issue #45.
val malScore: Double? = null
)

data class StreamingServiceUi(
Expand Down Expand Up @@ -163,7 +167,8 @@ class DetailsViewModel @Inject constructor(
private val watchHistoryRepository: WatchHistoryRepository,
private val watchlistRepository: WatchlistRepository,
private val cloudSyncRepository: CloudSyncRepository,
private val launcherContinueWatchingRepository: LauncherContinueWatchingRepository
private val launcherContinueWatchingRepository: LauncherContinueWatchingRepository,
private val animeScoreRepository: AnimeScoreRepository
) : ViewModel() {

private val _uiState = MutableStateFlow(DetailsUiState())
Expand Down Expand Up @@ -381,6 +386,25 @@ class DetailsViewModel @Inject constructor(
val prefetchSeason = if (mediaType == MediaType.TV) (initialSeason ?: 1) else null
val prefetchEpisode = if (mediaType == MediaType.TV) (initialEpisode ?: 1) else null
prefetchStreamsInBackground(imdbId, prefetchSeason, prefetchEpisode)

// MAL score fetch for anime. Gated on isAnimeContent so we don't
// hit Jikan for live-action content. Runs in a detached launch so
// it never blocks the main details load, and failures are swallowed
// by AnimeScoreRepository (null score just hides the badge). Issue #45.
val currentItem = _uiState.value.item
val isAnime = com.arflix.tv.util.AnimeMapper.isAnimeContentStatic(
tmdbId = mediaId,
genreIds = currentItem?.genreIds ?: emptyList(),
originalLanguage = currentItem?.originalLanguage
)
if (isAnime) {
launch {
val score = animeScoreRepository.getMalScore(imdbId)
if (score != null) {
updateState { state -> state.copy(malScore = score) }
}
}
}
} else if (tvdbId != null) {
updateState { state -> state.copy(tvdbId = tvdbId) }
}
Expand Down
Loading