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 |package com.nuvio.app.features.settings
| |
|object CommunityConfig { |object CommunityConfig {
| const val CONTRIBUTIONS_URL = "${props.getProperty("CONTRIBUTIONS_URL", "")}"
| const val DONATIONS_BASE_URL = "${props.getProperty("DONATIONS_BASE_URL", "")}" | const val DONATIONS_BASE_URL = "${props.getProperty("DONATIONS_BASE_URL", "")}"
| const val DONATIONS_DONATE_URL = "${props.getProperty("DONATIONS_DONATE_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.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.core.ui.NuvioSurfaceCard
import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -83,12 +80,16 @@ private data class CommunityUiState(
) )
@Serializable @Serializable
private data class GitHubContributorDto( private data class ContributionsResponseDto(
val login: String? = null, val contributors: List<ContributionDto> = emptyList(),
@SerialName("avatar_url") val avatarUrl: String? = null, )
@SerialName("html_url") val htmlUrl: String? = null,
val contributions: Int? = null, @Serializable
val type: String? = null, private data class ContributionDto(
val name: String? = null,
val avatar: String? = null,
val profile: String? = null,
val total: Int? = null,
) )
@Serializable @Serializable
@ -108,9 +109,6 @@ internal data class CommunityContributor(
val avatarUrl: String?, val avatarUrl: String?,
val profileUrl: String?, val profileUrl: String?,
val totalContributions: Int, val totalContributions: Int,
val mobileContributions: Int,
val tvContributions: Int,
val webContributions: Int,
) )
internal data class SupporterDonation( internal data class SupporterDonation(
@ -122,39 +120,31 @@ internal data class SupporterDonation(
) )
private object SupportersContributorsRepository { 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 } private val json = Json { ignoreUnknownKeys = true; isLenient = true }
suspend fun getContributors(): Result<List<CommunityContributor>> = runCatching { suspend fun getContributors(): Result<List<CommunityContributor>> = runCatching {
coroutineScope { val contributionsUrl = CommunityConfig.CONTRIBUTIONS_URL.trim()
val mobileDeferred = async { fetchRepoContributors(mobileRepository) } check(contributionsUrl.isNotBlank()) {
val tvDeferred = async { fetchRepoContributors(tvRepository) } getString(Res.string.community_error_unable_load_contributors)
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 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 { 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 { private fun normalizeContributor(dto: ContributionDto): CommunityContributor? {
val contributors = mutableListOf<GitHubContributorDto>() val login = dto.name?.trim().orEmpty()
var nextUrl: String? = "$gitHubApiBase/repos/$gitHubOwner/$repo/contributors?per_page=100" val contributions = dto.total ?: 0
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()
if (login.isBlank() || contributions <= 0) return null if (login.isBlank() || contributions <= 0) return null
if (type != null && !type.equals("User", ignoreCase = true)) return null
return NormalizedContributor( return CommunityContributor(
login = login, login = login,
avatarUrl = dto.avatarUrl?.trim()?.takeIf { it.isNotBlank() }, avatarUrl = dto.avatar?.trim()?.takeIf { it.isNotBlank() },
htmlUrl = dto.htmlUrl?.trim()?.takeIf { it.isNotBlank() }, profileUrl = dto.profile?.trim()?.takeIf { it.isNotBlank() },
contributions = contributions, 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 { private fun supporterSortTimestamp(rawDate: String): Long {
val datePart = rawDate.substringBefore('T') val datePart = rawDate.substringBefore('T')
val parts = datePart.split('-') val parts = datePart.split('-')
@ -324,22 +206,6 @@ private object SupportersContributorsRepository {
val day = parts[2].toLongOrNull() ?: return Long.MIN_VALUE val day = parts[2].toLongOrNull() ?: return Long.MIN_VALUE
return year * 10_000L + month * 100L + day 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 @Composable
@ -656,7 +522,7 @@ private fun ContributorsCard(
) { ) {
items( items(
items = contributors, items = contributors,
key = { contributor -> contributor.login.lowercase() }, key = { contributor -> "${contributor.login.lowercase()}-${contributor.profileUrl.orEmpty()}" },
) { contributor -> ) { contributor ->
ContributorRow( ContributorRow(
contributor = contributor, contributor = contributor,

View file

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