diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/JikanApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/JikanApi.kt new file mode 100644 index 000000000..5d686dc07 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/api/JikanApi.kt @@ -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? +) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/AnimeScoreRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/AnimeScoreRepository.kt new file mode 100644 index 000000000..ef0ffd8b3 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/AnimeScoreRepository.kt @@ -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 \u2014 nulls cached to avoid re-hitting ARM for negative results. + private val malIdCache: MutableMap = + Collections.synchronizedMap(LinkedHashMap()) + + // Map \u2014 same semantics as above. + private val scoreCache: MutableMap = + 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 trimCache(cache: MutableMap) { + 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-- + } + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt index 0bdde0236..ad04163c0 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -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) + } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index 09690f7ef..4ae3e59f2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -598,6 +598,7 @@ fun DetailsScreen( usePosterCards = usePosterCards, isMobile = isMobile, onBack = onBack, + malScore = uiState.malScore, onButtonClick = { idx -> when (idx) { 0 -> { // Play @@ -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 = {}, @@ -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)) @@ -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( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index 0ba81b849..ea7cc0157 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -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 @@ -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( @@ -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()) @@ -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) } }