feat: update contributions api

This commit is contained in:
tapframe 2026-04-25 12:14:23 +05:30
parent 84a4771f67
commit 99d821e7db
3 changed files with 42 additions and 175 deletions

View file

@ -97,6 +97,7 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|package com.nuvio.app.features.settings
|
|object CommunityConfig {
| const val CONTRIBUTIONS_URL = "${props.getProperty("CONTRIBUTIONS_URL", "")}"
| const val DONATIONS_BASE_URL = "${props.getProperty("DONATIONS_BASE_URL", "")}"
| const val DONATIONS_DONATE_URL = "${props.getProperty("DONATIONS_DONATE_URL", "")}"
|}

View file

@ -54,10 +54,7 @@ import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioSurfaceCard
import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
@ -83,12 +80,16 @@ private data class CommunityUiState(
)
@Serializable
private data class GitHubContributorDto(
val login: String? = null,
@SerialName("avatar_url") val avatarUrl: String? = null,
@SerialName("html_url") val htmlUrl: String? = null,
val contributions: Int? = null,
val type: String? = null,
private data class ContributionsResponseDto(
val contributors: List<ContributionDto> = emptyList(),
)
@Serializable
private data class ContributionDto(
val name: String? = null,
val avatar: String? = null,
val profile: String? = null,
val total: Int? = null,
)
@Serializable
@ -108,9 +109,6 @@ internal data class CommunityContributor(
val avatarUrl: String?,
val profileUrl: String?,
val totalContributions: Int,
val mobileContributions: Int,
val tvContributions: Int,
val webContributions: Int,
)
internal data class SupporterDonation(
@ -122,39 +120,31 @@ internal data class SupporterDonation(
)
private object SupportersContributorsRepository {
private const val gitHubOwner = "nuviomedia"
private const val mobileRepository = "nuviomobile"
private const val tvRepository = "nuviotv"
private const val webRepository = "nuvioweb"
private const val gitHubApiBase = "https://api.github.com"
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
suspend fun getContributors(): Result<List<CommunityContributor>> = runCatching {
coroutineScope {
val mobileDeferred = async { fetchRepoContributors(mobileRepository) }
val tvDeferred = async { fetchRepoContributors(tvRepository) }
val webDeferred = async { fetchRepoContributors(webRepository) }
val mobileResult = mobileDeferred.await()
val tvResult = tvDeferred.await()
val webResult = webDeferred.await()
if (mobileResult.isFailure && tvResult.isFailure && webResult.isFailure) {
throw (
mobileResult.exceptionOrNull()
?: tvResult.exceptionOrNull()
?: webResult.exceptionOrNull()
?: IllegalStateException(getString(Res.string.community_error_unable_load_contributors))
)
}
mergeContributors(
mobileContributors = mobileResult.getOrDefault(emptyList()),
tvContributors = tvResult.getOrDefault(emptyList()),
webContributors = webResult.getOrDefault(emptyList()),
)
val contributionsUrl = CommunityConfig.CONTRIBUTIONS_URL.trim()
check(contributionsUrl.isNotBlank()) {
getString(Res.string.community_error_unable_load_contributors)
}
val response = httpRequestRaw(
method = "GET",
url = contributionsUrl,
headers = emptyMap(),
body = "",
)
if (response.status !in 200..299) {
error(getString(Res.string.community_error_contributors_request_failed))
}
json.decodeFromString<ContributionsResponseDto>(response.body)
.contributors
.mapNotNull(::normalizeContributor)
.sortedWith(
compareByDescending<CommunityContributor> { it.totalContributions }
.thenBy { it.login.lowercase() },
)
}
suspend fun getSupporters(limit: Int = 200): Result<List<SupporterDonation>> = runCatching {
@ -194,127 +184,19 @@ private object SupportersContributorsRepository {
}
}
private suspend fun fetchRepoContributors(repo: String): Result<List<GitHubContributorDto>> = runCatching {
val contributors = mutableListOf<GitHubContributorDto>()
var nextUrl: String? = "$gitHubApiBase/repos/$gitHubOwner/$repo/contributors?per_page=100"
while (nextUrl != null) {
val response = httpRequestRaw(
method = "GET",
url = nextUrl,
headers = mapOf(
"Accept" to "application/vnd.github+json",
"User-Agent" to "NuvioMobile",
),
body = "",
)
if (response.status !in 200..299) {
error(getString(Res.string.community_error_contributors_request_failed))
}
contributors += json.decodeFromString<List<GitHubContributorDto>>(response.body)
nextUrl = response.headers.entries
.firstOrNull { it.key.equals("link", ignoreCase = true) }
?.value
?.let(::parseNextLink)
}
contributors
}
private fun mergeContributors(
mobileContributors: List<GitHubContributorDto>,
tvContributors: List<GitHubContributorDto>,
webContributors: List<GitHubContributorDto>,
): List<CommunityContributor> {
val contributorsByLogin = linkedMapOf<String, MutableCommunityContributor>()
mobileContributors.forEach { dto ->
normalizeContributor(dto)?.let { contributor ->
val entry = contributorsByLogin.getOrPut(contributor.login.lowercase()) {
MutableCommunityContributor(
login = contributor.login,
avatarUrl = contributor.avatarUrl,
profileUrl = contributor.htmlUrl,
)
}
entry.avatarUrl = entry.avatarUrl ?: contributor.avatarUrl
entry.profileUrl = entry.profileUrl ?: contributor.htmlUrl
entry.mobileContributions += contributor.contributions
}
}
tvContributors.forEach { dto ->
normalizeContributor(dto)?.let { contributor ->
val entry = contributorsByLogin.getOrPut(contributor.login.lowercase()) {
MutableCommunityContributor(
login = contributor.login,
avatarUrl = contributor.avatarUrl,
profileUrl = contributor.htmlUrl,
)
}
entry.avatarUrl = entry.avatarUrl ?: contributor.avatarUrl
entry.profileUrl = entry.profileUrl ?: contributor.htmlUrl
entry.tvContributions += contributor.contributions
}
}
webContributors.forEach { dto ->
normalizeContributor(dto)?.let { contributor ->
val entry = contributorsByLogin.getOrPut(contributor.login.lowercase()) {
MutableCommunityContributor(
login = contributor.login,
avatarUrl = contributor.avatarUrl,
profileUrl = contributor.htmlUrl,
)
}
entry.avatarUrl = entry.avatarUrl ?: contributor.avatarUrl
entry.profileUrl = entry.profileUrl ?: contributor.htmlUrl
entry.webContributions += contributor.contributions
}
}
return contributorsByLogin.values
.map { contributor ->
CommunityContributor(
login = contributor.login,
avatarUrl = contributor.avatarUrl,
profileUrl = contributor.profileUrl,
totalContributions = contributor.mobileContributions + contributor.tvContributions + contributor.webContributions,
mobileContributions = contributor.mobileContributions,
tvContributions = contributor.tvContributions,
webContributions = contributor.webContributions,
)
}
.sortedWith(
compareByDescending<CommunityContributor> { it.totalContributions }
.thenBy { it.login.lowercase() },
)
}
private fun normalizeContributor(dto: GitHubContributorDto): NormalizedContributor? {
val login = dto.login?.trim().orEmpty()
val contributions = dto.contributions ?: 0
val type = dto.type?.trim()
private fun normalizeContributor(dto: ContributionDto): CommunityContributor? {
val login = dto.name?.trim().orEmpty()
val contributions = dto.total ?: 0
if (login.isBlank() || contributions <= 0) return null
if (type != null && !type.equals("User", ignoreCase = true)) return null
return NormalizedContributor(
return CommunityContributor(
login = login,
avatarUrl = dto.avatarUrl?.trim()?.takeIf { it.isNotBlank() },
htmlUrl = dto.htmlUrl?.trim()?.takeIf { it.isNotBlank() },
contributions = contributions,
avatarUrl = dto.avatar?.trim()?.takeIf { it.isNotBlank() },
profileUrl = dto.profile?.trim()?.takeIf { it.isNotBlank() },
totalContributions = contributions,
)
}
private fun parseNextLink(linkHeader: String): String? =
linkHeader.split(',')
.map(String::trim)
.firstOrNull { it.contains("rel=\"next\"") }
?.substringAfter('<')
?.substringBefore('>')
?.takeIf { it.isNotBlank() }
private fun supporterSortTimestamp(rawDate: String): Long {
val datePart = rawDate.substringBefore('T')
val parts = datePart.split('-')
@ -324,22 +206,6 @@ private object SupportersContributorsRepository {
val day = parts[2].toLongOrNull() ?: return Long.MIN_VALUE
return year * 10_000L + month * 100L + day
}
private data class NormalizedContributor(
val login: String,
val avatarUrl: String?,
val htmlUrl: String?,
val contributions: Int,
)
private data class MutableCommunityContributor(
val login: String,
var avatarUrl: String?,
var profileUrl: String?,
var mobileContributions: Int = 0,
var tvContributions: Int = 0,
var webContributions: Int = 0,
)
}
@Composable
@ -656,7 +522,7 @@ private fun ContributorsCard(
) {
items(
items = contributors,
key = { contributor -> contributor.login.lowercase() },
key = { contributor -> "${contributor.login.lowercase()}-${contributor.profileUrl.orEmpty()}" },
) { contributor ->
ContributorRow(
contributor = contributor,

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=38
MARKETING_VERSION=0.1.7
CURRENT_PROJECT_VERSION=40
MARKETING_VERSION=0.1.9