mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
feat: update contributions api
This commit is contained in:
parent
84a4771f67
commit
99d821e7db
3 changed files with 42 additions and 175 deletions
|
|
@ -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", "")}"
|
||||||
|}
|
|}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
val response = httpRequestRaw(
|
||||||
mobileContributors = mobileResult.getOrDefault(emptyList()),
|
method = "GET",
|
||||||
tvContributors = tvResult.getOrDefault(emptyList()),
|
url = contributionsUrl,
|
||||||
webContributors = webResult.getOrDefault(emptyList()),
|
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,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
CURRENT_PROJECT_VERSION=38
|
CURRENT_PROJECT_VERSION=40
|
||||||
MARKETING_VERSION=0.1.7
|
MARKETING_VERSION=0.1.9
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue