From a4a71174dc6f6b28181a993fd6559534c5199f98 Mon Sep 17 00:00:00 2001 From: Arvin Date: Sun, 5 Apr 2026 15:36:27 +0200 Subject: [PATCH 1/2] feat: MyAnimeList community score on anime details (#45) Adds a MAL score badge next to the IMDb badge on anime details pages. The flow is: IMDB id -> ARM API (imdb -> mal_id) -> Jikan v4 (mal_id -> score) ARVIO already used the ARM API in SkipIntroRepository for the AniSkip intro-marker feature, so the IMDB->MAL hop is a known working path. Jikan v4 is the unofficial MyAnimeList REST API and is widely used by anime apps for community score display. Changes: - New JikanApi interface (GET /anime/{malId}) with JikanAnimeResponse and JikanAnimeData models that deserialize the `score` field. - New AnimeScoreRepository (@Singleton) that wraps the IMDB->MAL->score resolution chain with in-memory LRU caching (up to 256 entries each for imdbId->malId and malId->score, including negative caching to avoid re-hitting ARM for titles that aren't in its database). Uses withTimeoutOrNull(2s) on each hop so slow responses don't stall the details load. All exceptions are swallowed to null \u2014 the caller hides the badge on null. - AppModule provides a new Retrofit instance for Jikan and binds JikanApi via Hilt, matching the existing ArmApi provider pattern. - DetailsUiState gains `malScore: Double?` (nullable, default null). - DetailsViewModel now takes AnimeScoreRepository and fetches the MAL score in a detached launch off the existing external-IDs resolver. Gated on AnimeMapper.isAnimeContentStatic(tmdbId, genreIds, originalLanguage) so we don't hit Jikan for live-action content. Runs in parallel with the main details load; never blocks rendering. - DetailsScreen shows a cyan/blue MAL badge in the metadata row after the IMDb badge, only when `uiState.malScore > 0.0`. Rendered in both the mobile layout (~line 1100) and the TV layout (~line 1530), using the MAL brand color `#2E51A2` to distinguish it from IMDB yellow. Jikan rate limit (~3 req/s, 60 req/min unofficial) is handled by the LRU cache \u2014 a typical session performs at most one Jikan call per unique anime visited, and results persist until the app process dies. Closes #45 --- .../kotlin/com/arflix/tv/data/api/JikanApi.kt | 38 ++++++++ .../data/repository/AnimeScoreRepository.kt | 93 +++++++++++++++++++ .../main/kotlin/com/arflix/tv/di/AppModule.kt | 17 ++++ .../tv/ui/screens/details/DetailsScreen.kt | 49 ++++++++++ .../tv/ui/screens/details/DetailsViewModel.kt | 28 +++++- 5 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/data/api/JikanApi.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/AnimeScoreRepository.kt 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..79ebfcc29 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 @@ -1101,6 +1101,31 @@ 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. + val malScoreValue = uiState.malScore + if (malScoreValue != null && malScoreValue > 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", malScoreValue), + style = ArflixTypography.caption.copy(fontSize = 10.sp, fontWeight = FontWeight.Bold), + color = Color.White + ) + } + } } Spacer(modifier = Modifier.height(10.dp)) @@ -1512,6 +1537,30 @@ private fun DetailsContent( } } + // MAL community score badge for anime. Issue #45. + val tvMalScore = uiState.malScore + if (tvMalScore != null && tvMalScore > 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", tvMalScore), + 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) } } From eb48ccc9aad42d7414e02d2da666719e64d226bd Mon Sep 17 00:00:00 2001 From: Arvin Date: Sun, 5 Apr 2026 15:43:43 +0200 Subject: [PATCH 2/2] fix: thread malScore param through DetailsContent (scope fix) --- .../arflix/tv/ui/screens/details/DetailsScreen.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 79ebfcc29..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 = {}, @@ -1104,8 +1108,7 @@ 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. - val malScoreValue = uiState.malScore - if (malScoreValue != null && malScoreValue > 0.0) { + 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, @@ -1120,7 +1123,7 @@ private fun DetailsContent( color = Color.White ) Text( - text = String.format("%.1f", malScoreValue), + text = String.format("%.1f", malScore), style = ArflixTypography.caption.copy(fontSize = 10.sp, fontWeight = FontWeight.Bold), color = Color.White ) @@ -1538,8 +1541,7 @@ private fun DetailsContent( } // MAL community score badge for anime. Issue #45. - val tvMalScore = uiState.malScore - if (tvMalScore != null && tvMalScore > 0.0) { + if (malScore != null && malScore > 0.0) { Text(text = "|", style = separatorStyle, color = Color.White.copy(alpha = 0.7f)) Row( verticalAlignment = Alignment.CenterVertically, @@ -1554,7 +1556,7 @@ private fun DetailsContent( color = Color.White ) Text( - text = String.format("%.1f", tvMalScore), + text = String.format("%.1f", malScore), style = ArflixTypography.caption.copy(fontSize = 11.sp, fontWeight = FontWeight.Bold), color = Color.White )