This commit is contained in:
iazifofficial 2026-07-23 18:57:25 -04:00 committed by GitHub
commit 0db78d3a1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 917 additions and 8 deletions

3
.gitignore vendored
View file

@ -22,6 +22,9 @@ node_modules/
logs/
analysis/
Docs
!Docs/
Docs/*
!Docs/NUVIO_PULL_REQUEST.md
keystore/
scripts/build-distribution.sh
asset

View file

@ -0,0 +1,76 @@
## Summary
Draft implementation of a native, localized Parents Guide accordion on movie and series details. It uses a separately hosted, open-source metadata-only API and does not change streams, playback, or source selection.
## PR type
- [ ] Reproducible bug fix
- [ ] UI glitch/bug fix
- [ ] Behavior bug/regression fix
- [ ] Small maintenance only, with no UI or behavior change
- [ ] Docs accuracy fix
- [ ] Translation/localization only
- [ ] Approved larger or directional change
This remains unchecked while the feature request is awaiting explicit maintainer approval.
## Why
Families cannot currently review sourced category summaries or scene-level parental advisories before playback. The proposed section covers sex and nudity, violence and gore, profanity, alcohol/drugs/smoking, and frightening/intense scenes without affecting the normal details flow when data is unavailable.
## Issue or approval
Awaiting a maintainer decision in #1601. This PR is intentionally a draft and is not submitted as review-ready before approval.
## UI / behavior impact
- [ ] No UI change
- [ ] No behavior change
- [ ] UI changed only to fix a documented glitch/bug
- [ ] Behavior changed only to fix a documented bug/regression
- [ ] UI change has explicit maintainer approval
- [ ] Behavior change has explicit maintainer approval
The implementation adds UI and network/cache behavior. The approval boxes will remain unchecked unless maintainers approve #1601.
## Policy check
- [x] I have read and understood `CONTRIBUTING.md`.
- [x] This PR is small, focused, and limited to one problem.
- [x] This PR is not cosmetic-only.
- [ ] Any UI change fixes a linked glitch/bug and includes visual proof, or this PR has no UI change.
- [ ] Any behavior change fixes a linked bug/regression or has explicit approval, or this PR has no behavior change.
- [x] This PR does not bundle unrelated refactors, cleanups, formatting, or drive-by changes.
- [x] This PR does not add dependencies, architecture changes, migrations, or product-direction changes without explicit approval.
- [x] I listed the testing performed below.
The approval-dependent checks will be completed only after explicit approval and visual proof.
## Scope boundaries
- No streaming resource, torrent behavior, playback-source behavior, or navigation redesign.
- Sends only the current title identifiers, media type, optional season/episode, and language.
- API errors are isolated from metadata loading and the rest of the details page.
- Uses the existing Show Parental Guide preference as the master switch.
- Initial cache is process-local; persistent settings/cache are outside this draft.
## Testing
- Backend: formatting, lint, strict type-check, 12 Vitest tests, production build, Vercel deployment, Neon readiness, and external endpoint smoke tests passed.
- Mobile: `./gradlew :composeApp:compileKotlinIosSimulatorArm64` passed after the production endpoint was configured.
- iOS tests compiled and linked, but simulator execution could not complete because the local simulator service hung.
- Android was not verified because the Android SDK is unavailable in the current environment.
Added mobile tests cover JSON/status/provenance/timestamps, partial success, network error isolation, cache hit/expiry, identifier resolution, category ordering/severity, spoiler filtering, and timestamp formatting.
## Screenshots / Video (UI changes only)
Pending feature approval and access to a working simulator/device. Before review, this draft needs collapsed, expanded-category, scene-detail, partial, unavailable, error, dark/light, large-text, and RTL captures.
## Breaking changes
None. With the feature disabled, no request or section is added. Missing guides and API failures leave the normal details page functional.
## Linked issues
#1601 — awaiting explicit maintainer approval.

View file

@ -43,6 +43,9 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
@get:Input
abstract val realtimeSyncEnabled: Property<Boolean>
@get:Input
abstract val parentsGuideApiBaseUrl: Property<String>
@TaskAction
fun generate() {
val props = Properties()
@ -176,6 +179,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
""".trimMargin()
)
}
outDir.resolve("com/nuvio/app/features/parentsguide").apply {
mkdirs()
resolve("ParentsGuideConfig.kt").writeText(
"""
|package com.nuvio.app.features.parentsguide
|
|object ParentsGuideConfig {
| const val API_BASE_URL = "${parentsGuideApiBaseUrl.get()}"
|}
""".trimMargin()
)
}
}
}
@ -301,6 +317,12 @@ val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generat
}
)
realtimeSyncEnabled.set(runtimeConfigBoolean("NUVIO_REALTIME_SYNC_ENABLED", true))
parentsGuideApiBaseUrl.set(
runtimeConfigValue(
"PARENTS_GUIDE_API_BASE_URL",
"https://nuvio-parents-guide-addon.vercel.app",
)
)
}
tasks.withType<KotlinCompilationTask<*>>().configureEach {

View file

@ -2058,4 +2058,34 @@
<item quantity="one">%d title</item>
<item quantity="other">%d titles</item>
</plurals>
<!-- Parents Guide details integration -->
<string name="parents_guide_title">Parents Guide</string>
<string name="parents_guide_sex_nudity">Sex &amp; Nudity</string>
<string name="parents_guide_violence_gore">Violence &amp; Gore</string>
<string name="parents_guide_profanity">Profanity</string>
<string name="parents_guide_alcohol_drugs_smoking">Alcohol, Drugs &amp; Smoking</string>
<string name="parents_guide_frightening_intense">Frightening &amp; Intense Scenes</string>
<string name="parents_guide_severity_none">None</string>
<string name="parents_guide_severity_mild">Mild</string>
<string name="parents_guide_severity_moderate">Moderate</string>
<string name="parents_guide_severity_severe">Severe</string>
<string name="parents_guide_severity_unknown">Unknown</string>
<string name="parents_guide_show_scenes">Show scenes</string>
<string name="parents_guide_hide_scenes">Hide scenes</string>
<string name="parents_guide_hide_spoilers">Hide spoilers</string>
<string name="parents_guide_show_spoilers">Show spoilers</string>
<string name="parents_guide_spoiler_hidden">Scene description hidden to avoid spoilers.</string>
<string name="parents_guide_unavailable">No detailed Parents Guide is available for this title yet.</string>
<string name="parents_guide_partial">Available information may be incomplete.</string>
<string name="parents_guide_series_fallback">Showing general guidance for this series.</string>
<string name="parents_guide_contribute">Contribute a guide</string>
<string name="parents_guide_retry">Retry</string>
<string name="parents_guide_source">Source</string>
<string name="parents_guide_verified">Verified</string>
<string name="parents_guide_community_submitted">Community submitted</string>
<string name="parents_guide_timestamp_disclaimer">Timestamp may vary by release version.</string>
<string name="parents_guide_loading">Loading Parents Guide…</string>
<string name="parents_guide_error">Parents Guide could not be loaded.</string>
<string name="parents_guide_expand">Expand Parents Guide</string>
<string name="parents_guide_collapse">Collapse Parents Guide</string>
</resources>

View file

@ -106,6 +106,11 @@ import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.parentsguide.ParentsGuideConfig
import com.nuvio.app.features.parentsguide.ParentsGuideRepository
import com.nuvio.app.features.parentsguide.ParentsGuideSection
import com.nuvio.app.features.parentsguide.ParentsGuideUiState
import com.nuvio.app.features.parentsguide.resolveParentsGuideRequest
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.tmdb.TmdbService
@ -516,6 +521,21 @@ fun MetaDetailsScreen(
meta.trailers.isNotEmpty()
}
val uriHandler = LocalUriHandler.current
var parentsGuideState by remember(meta.id) { mutableStateOf<ParentsGuideUiState>(ParentsGuideUiState.Loading) }
var parentsGuideRetryToken by remember(meta.id) { mutableIntStateOf(0) }
val parentsGuideRequest = remember(meta.type, meta.id) { resolveParentsGuideRequest(meta.type, meta.id) }
LaunchedEffect(meta.id, parentsGuideRetryToken, playerSettingsUiState.showParentalGuide) {
if (!playerSettingsUiState.showParentalGuide || parentsGuideRequest == null) {
parentsGuideState = ParentsGuideUiState.Unavailable()
return@LaunchedEffect
}
parentsGuideState = ParentsGuideUiState.Loading
parentsGuideState = ParentsGuideRepository.load(
apiBaseUrl = ParentsGuideConfig.API_BASE_URL,
request = parentsGuideRequest,
forceRefresh = parentsGuideRetryToken > 0,
)
}
val inAppTrailerPlaybackEnabled = AppFeaturePolicy.trailerPlaybackMode == TrailerPlaybackMode.IN_APP
val trailerScope = rememberCoroutineScope()
var selectedTrailer by remember(meta.id) { mutableStateOf<MetaTrailer?>(null) }
@ -1005,6 +1025,14 @@ fun MetaDetailsScreen(
onCompanyClick = onCompanyClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
showParentsGuide = playerSettingsUiState.showParentalGuide,
parentsGuideState = parentsGuideState,
onRetryParentsGuide = { parentsGuideRetryToken += 1 },
onContributeParentsGuide = {
ParentsGuideConfig.API_BASE_URL.takeIf(String::isNotBlank)?.let { baseUrl ->
runCatching { uriHandler.openUri("${baseUrl.trimEnd('/')}/configure") }
}
},
)
item(key = "detail-bottom-spacer") {
@ -1613,6 +1641,10 @@ private fun LazyListScope.configuredMetaSectionItems(
onCompanyClick: ((MetaCompany, String) -> Unit)?,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
showParentsGuide: Boolean,
parentsGuideState: ParentsGuideUiState,
onRetryParentsGuide: () -> Unit,
onContributeParentsGuide: () -> Unit,
) {
val enabledItems = settings.items.filter { it.enabled }
fun sectionHasContent(key: MetaScreenSectionKey): Boolean =
@ -1694,29 +1726,62 @@ private fun LazyListScope.configuredMetaSectionItems(
}
}
if (!settings.tabLayout) {
enabledItems
.filter { sectionHasContent(it.key) }
.forEach { section ->
addSectionItem(
key = "detail-section-${section.key.name}",
sectionItems = listOf(section),
forceTabLayout = false,
fun addParentsGuideItem() {
if (!showParentsGuide) return
item(key = "detail-parents-guide") {
DetailSectionContainer(
horizontalPadding = contentHorizontalPadding,
contentMaxWidth = contentMaxWidth,
) {
ParentsGuideSection(
state = parentsGuideState,
onRetry = onRetryParentsGuide,
onContribute = onContributeParentsGuide,
)
}
}
}
if (!settings.tabLayout) {
var parentsGuideInserted = false
enabledItems.filter { sectionHasContent(it.key) }.forEach { section ->
if (!parentsGuideInserted && section.key == MetaScreenSectionKey.MORE_LIKE_THIS) {
addParentsGuideItem()
parentsGuideInserted = true
}
addSectionItem(
key = "detail-section-${section.key.name}",
sectionItems = listOf(section),
forceTabLayout = false,
)
if (!parentsGuideInserted && section.key == MetaScreenSectionKey.DETAILS) {
addParentsGuideItem()
parentsGuideInserted = true
}
}
if (!parentsGuideInserted) addParentsGuideItem()
return
}
val processedGroups = mutableSetOf<Int>()
var parentsGuideInserted = false
enabledItems.forEach { section ->
val groupId = section.tabGroup
if (groupId == null) {
if (sectionHasContent(section.key)) {
if (!parentsGuideInserted && section.key == MetaScreenSectionKey.MORE_LIKE_THIS) {
addParentsGuideItem()
parentsGuideInserted = true
}
addSectionItem(
key = "detail-section-${section.key.name}",
sectionItems = listOf(section),
forceTabLayout = true,
)
if (!parentsGuideInserted && section.key == MetaScreenSectionKey.DETAILS) {
addParentsGuideItem()
parentsGuideInserted = true
}
}
} else if (groupId !in processedGroups) {
processedGroups.add(groupId)
@ -1724,14 +1789,23 @@ private fun LazyListScope.configuredMetaSectionItems(
item.tabGroup == groupId && sectionHasContent(item.key)
}
if (groupMembers.isNotEmpty()) {
if (!parentsGuideInserted && groupMembers.any { it.key == MetaScreenSectionKey.MORE_LIKE_THIS }) {
addParentsGuideItem()
parentsGuideInserted = true
}
addSectionItem(
key = "detail-section-group-$groupId",
sectionItems = groupMembers,
forceTabLayout = groupMembers.size > 1,
)
if (!parentsGuideInserted && groupMembers.any { it.key == MetaScreenSectionKey.DETAILS }) {
addParentsGuideItem()
parentsGuideInserted = true
}
}
}
}
if (!parentsGuideInserted) addParentsGuideItem()
}
@Composable

View file

@ -0,0 +1,138 @@
package com.nuvio.app.features.parentsguide
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ParentsGuideEnvelope(
val success: Boolean,
val data: ParentsGuideData,
)
@Serializable
data class ParentsGuideData(
val identity: ParentsGuideIdentity,
val guide: ParentsGuide,
)
@Serializable
data class ParentsGuideIdentity(
val mediaType: String,
val title: String = "",
val releaseYear: Int? = null,
val imdbId: String? = null,
val tmdbId: Int? = null,
val stremioId: String? = null,
val seasonNumber: Int? = null,
val episodeNumber: Int? = null,
)
@Serializable
data class ParentsGuide(
val id: String,
val overallStatus: ParentsGuideStatus,
val overallSummary: String? = null,
val minimumSuggestedAge: Int? = null,
val editionLabel: String? = null,
val contentVersion: Int = 1,
val categories: List<ParentsGuideCategory> = emptyList(),
val sources: List<ParentsGuideSource> = emptyList(),
)
@Serializable
enum class ParentsGuideStatus {
@SerialName("available") AVAILABLE,
@SerialName("partial") PARTIAL,
@SerialName("unavailable") UNAVAILABLE,
@SerialName("disputed") DISPUTED,
}
@Serializable
data class ParentsGuideCategory(
val category: ParentsGuideCategoryType,
val label: String = "",
val severity: ParentsGuideSeverity,
val summary: String? = null,
val spoilerLevel: ParentsGuideSpoilerLevel = ParentsGuideSpoilerLevel.NONE,
val scenes: List<ParentsGuideScene> = emptyList(),
)
@Serializable
enum class ParentsGuideCategoryType {
@SerialName("sex_nudity") SEX_NUDITY,
@SerialName("violence_gore") VIOLENCE_GORE,
@SerialName("profanity") PROFANITY,
@SerialName("alcohol_drugs_smoking") ALCOHOL_DRUGS_SMOKING,
@SerialName("frightening_intense") FRIGHTENING_INTENSE,
}
@Serializable
enum class ParentsGuideSeverity {
@SerialName("none") NONE,
@SerialName("mild") MILD,
@SerialName("moderate") MODERATE,
@SerialName("severe") SEVERE,
@SerialName("unknown") UNKNOWN,
}
@Serializable
enum class ParentsGuideSpoilerLevel {
@SerialName("none") NONE,
@SerialName("minor") MINOR,
@SerialName("major") MAJOR,
}
@Serializable
data class ParentsGuideScene(
val description: String,
val startSeconds: Int? = null,
val endSeconds: Int? = null,
val seasonNumber: Int? = null,
val episodeNumber: Int? = null,
val spoilerLevel: ParentsGuideSpoilerLevel = ParentsGuideSpoilerLevel.NONE,
val verificationStatus: String = "unverified",
)
@Serializable
data class ParentsGuideSource(
val sourceType: String,
val sourceName: String,
val sourceUrl: String? = null,
val sourceLicense: String? = null,
val attributionText: String? = null,
)
data class ParentsGuideRequest(
val mediaType: String,
val imdbId: String? = null,
val tmdbId: Int? = null,
val stremioId: String? = null,
val season: Int? = null,
val episode: Int? = null,
)
sealed interface ParentsGuideUiState {
data object Loading : ParentsGuideUiState
data class Available(val data: ParentsGuideData, val fromCache: Boolean = false, val isSeriesFallback: Boolean = false) : ParentsGuideUiState
data class Unavailable(val fromCache: Boolean = false) : ParentsGuideUiState
data class Error(val hasCachedData: Boolean = false) : ParentsGuideUiState
}
internal val categoryOrder = ParentsGuideCategoryType.entries.withIndex().associate { it.value to it.index }
internal fun orderedCategories(categories: List<ParentsGuideCategory>): List<ParentsGuideCategory> =
categories.sortedBy { categoryOrder[it.category] ?: Int.MAX_VALUE }
internal fun visibleScenes(scenes: List<ParentsGuideScene>, showSpoilers: Boolean): List<ParentsGuideScene> =
scenes.filter { showSpoilers || it.spoilerLevel == ParentsGuideSpoilerLevel.NONE }
internal fun formatGuideTimestamp(seconds: Int): String {
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val remainder = seconds % 60
return if (hours > 0) {
"${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainder.toString().padStart(2, '0')}"
} else {
"${minutes.toString().padStart(2, '0')}:${remainder.toString().padStart(2, '0')}"
}
}

View file

@ -0,0 +1,90 @@
package com.nuvio.app.features.parentsguide
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
private val previewData = ParentsGuideData(
identity = ParentsGuideIdentity("movie", "Fictional preview"),
guide = ParentsGuide(
id = "preview",
overallStatus = ParentsGuideStatus.PARTIAL,
overallSummary = "Preview-only information with several content advisories.",
categories = listOf(
ParentsGuideCategory(
ParentsGuideCategoryType.SEX_NUDITY,
severity = ParentsGuideSeverity.MODERATE,
scenes = listOf(ParentsGuideScene("A concise preview description.", spoilerLevel = ParentsGuideSpoilerLevel.NONE)),
),
ParentsGuideCategory(
ParentsGuideCategoryType.VIOLENCE_GORE,
severity = ParentsGuideSeverity.SEVERE,
scenes = listOf(ParentsGuideScene("A long preview description used to verify wrapping at large text sizes without horizontal overflow.", startSeconds = 862, spoilerLevel = ParentsGuideSpoilerLevel.MAJOR)),
),
),
sources = listOf(ParentsGuideSource("moderator_entry", "Preview source", attributionText = "Preview only")),
),
)
@Composable
private fun PreviewFrame(content: @Composable () -> Unit) {
MaterialTheme { Box(Modifier.width(390.dp).padding(16.dp)) { content() } }
}
@Preview
@Composable
private fun ParentsGuideCollapsedPreview() = PreviewFrame {
ParentsGuideSection(ParentsGuideUiState.Available(previewData), onRetry = {}, onContribute = {})
}
@Preview
@Composable
private fun ParentsGuideExpandedPreview() = PreviewFrame {
ParentsGuideSection(ParentsGuideUiState.Available(previewData), initiallyExpanded = true, onRetry = {}, onContribute = {})
}
@Preview
@Composable
private fun ParentsGuideSevereCategoryPreview() = PreviewFrame {
ParentsGuideCategoryRow(previewData.guide.categories[1], expanded = true, showSpoilers = true, showTimestamps = true, onToggle = {})
}
@Preview
@Composable
private fun ParentsGuideUnavailablePreview() = PreviewFrame {
ParentsGuideSection(ParentsGuideUiState.Unavailable(), initiallyExpanded = true, onRetry = {}, onContribute = {})
}
@Preview
@Composable
private fun ParentsGuideLoadingPreview() = PreviewFrame {
ParentsGuideSection(ParentsGuideUiState.Loading, initiallyExpanded = true, onRetry = {}, onContribute = {})
}
@Preview
@Composable
private fun ParentsGuideErrorPreview() = PreviewFrame {
ParentsGuideSection(ParentsGuideUiState.Error(), initiallyExpanded = true, onRetry = {}, onContribute = {})
}
@Preview(fontScale = 1.8f)
@Composable
private fun ParentsGuideLargeTextPreview() = PreviewFrame {
ParentsGuideCategoryRow(previewData.guide.categories[1], expanded = true, showSpoilers = true, showTimestamps = true, onToggle = {})
}
@Preview
@Composable
private fun ParentsGuideRtlPreview() {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
PreviewFrame { ParentsGuideSection(ParentsGuideUiState.Available(previewData), initiallyExpanded = true, onRetry = {}, onContribute = {}) }
}
}

View file

@ -0,0 +1,155 @@
package com.nuvio.app.features.parentsguide
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.library.LibraryClock
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
private const val AvailableTtlMs = 24 * 60 * 60 * 1_000L
private const val UnavailableTtlMs = 60 * 60 * 1_000L
internal class ParentsGuideCache(
private val now: () -> Long,
) {
private data class Entry(val data: ParentsGuideData, val storedAt: Long)
private val values = mutableMapOf<String, Entry>()
fun get(key: String, allowStale: Boolean = false): ParentsGuideData? {
val entry = values[key] ?: return null
val ttl = if (entry.data.guide.overallStatus == ParentsGuideStatus.UNAVAILABLE) UnavailableTtlMs else AvailableTtlMs
return entry.data.takeIf { allowStale || now() - entry.storedAt <= ttl }
}
fun put(key: String, data: ParentsGuideData) {
values[key] = Entry(data, now())
}
fun clear(key: String) {
values.remove(key)
}
}
internal class ParentsGuideRemoteDataSource(
private val apiBaseUrl: String,
private val fetch: suspend (String) -> Pair<Int, String> = { url ->
httpRequestRaw("GET", url, mapOf("Accept" to "application/json"), "").let { it.status to it.body }
},
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun fetch(request: ParentsGuideRequest): ParentsGuideData {
require(apiBaseUrl.isNotBlank()) { "Parents Guide API is not configured" }
val query = buildList {
request.imdbId?.let { add("imdbId=$it") }
request.tmdbId?.let { add("tmdbId=$it") }
request.stremioId?.let { add("stremioId=${encodeQueryValue(it)}") }
add("mediaType=${normalizeMediaType(request.mediaType)}")
request.season?.let { add("season=$it") }
request.episode?.let { add("episode=$it") }
add("language=en")
}.joinToString("&")
val (status, body) = fetch("${apiBaseUrl.trimEnd('/')}/api/v1/guide?$query")
check(status in 200..299) { "Parents Guide request failed ($status)" }
return json.decodeFromString<ParentsGuideEnvelope>(body).data
}
}
internal class ParentsGuideClient(
private val remote: ParentsGuideRemoteDataSource,
private val cache: ParentsGuideCache,
) {
private val locks = mutableMapOf<String, Mutex>()
suspend fun load(request: ParentsGuideRequest, forceRefresh: Boolean = false): ParentsGuideUiState {
val key = request.cacheKey()
if (!forceRefresh) cache.get(key)?.let { return it.toUiState(fromCache = true) }
val lock = locks.getOrPut(key) { Mutex() }
return lock.withLock {
if (!forceRefresh) cache.get(key)?.let { return@withLock it.toUiState(fromCache = true) }
runCatching { remote.fetch(request) }
.fold(
onSuccess = { data ->
cache.put(key, data)
data.toUiState(fromCache = false)
},
onFailure = {
cache.get(key, allowStale = true)?.toUiState(fromCache = true)
?: ParentsGuideUiState.Error()
},
)
}
}
}
internal object ParentsGuideRepository {
private val log = Logger.withTag("ParentsGuideDetails")
private var configuredUrl: String? = null
private var client: ParentsGuideClient? = null
suspend fun load(apiBaseUrl: String, request: ParentsGuideRequest, forceRefresh: Boolean = false): ParentsGuideUiState {
if (apiBaseUrl.isBlank()) return ParentsGuideUiState.Unavailable()
if (client == null || configuredUrl != apiBaseUrl) {
configuredUrl = apiBaseUrl
client = ParentsGuideClient(
remote = ParentsGuideRemoteDataSource(apiBaseUrl),
cache = ParentsGuideCache(LibraryClock::nowEpochMs),
)
}
return runCatching {
val primary = client!!.load(request, forceRefresh)
if (request.episode != null && primary is ParentsGuideUiState.Unavailable) {
when (val fallback = client!!.load(request.copy(mediaType = "series", season = null, episode = null), forceRefresh)) {
is ParentsGuideUiState.Available -> fallback.copy(isSeriesFallback = true)
else -> primary
}
} else {
primary
}
}
.onFailure { log.w(it) { "Unable to load Parents Guide" } }
.getOrElse { ParentsGuideUiState.Error() }
}
}
internal fun resolveParentsGuideRequest(type: String, id: String): ParentsGuideRequest? {
val imdb = Regex("tt\\d{5,12}").find(id)?.value
val tmdb = id.split(':', '/', '|').let { parts ->
parts.getOrNull(parts.indexOfFirst { it.equals("tmdb", ignoreCase = true) } + 1)?.toIntOrNull()
?: id.takeIf { it.all(Char::isDigit) }?.toIntOrNull()
}
if (imdb == null && tmdb == null && id.isBlank()) return null
val numbers = id.split(':')
val season = numbers.takeLast(2).getOrNull(0)?.toIntOrNull()
val episode = numbers.lastOrNull()?.toIntOrNull().takeIf { numbers.size >= 3 }
return ParentsGuideRequest(
mediaType = normalizeMediaType(type),
imdbId = imdb,
tmdbId = tmdb,
stremioId = id.takeIf { it.isNotBlank() },
season = season,
episode = episode,
)
}
private fun ParentsGuideRequest.cacheKey(): String = listOf(mediaType, imdbId, tmdbId, stremioId, season, episode).joinToString("|")
private fun ParentsGuideData.toUiState(fromCache: Boolean): ParentsGuideUiState =
if (guide.overallStatus == ParentsGuideStatus.UNAVAILABLE) ParentsGuideUiState.Unavailable(fromCache)
else ParentsGuideUiState.Available(copy(guide = guide.copy(categories = orderedCategories(guide.categories))), fromCache)
private fun normalizeMediaType(type: String): String = when (type.lowercase()) {
"series", "show", "tv" -> "series"
"episode" -> "episode"
else -> "movie"
}
private fun encodeQueryValue(value: String): String = buildString {
value.forEach { char ->
when {
char.isLetterOrDigit() || char in "-._~" -> append(char)
else -> append('%').append(char.code.toString(16).uppercase().padStart(2, '0'))
}
}
}

View file

@ -0,0 +1,225 @@
package com.nuvio.app.features.parentsguide
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
@Composable
fun ParentsGuideSection(
state: ParentsGuideUiState,
showTimestamps: Boolean = true,
hideSpoilersByDefault: Boolean = true,
initiallyExpanded: Boolean = false,
onRetry: () -> Unit,
onContribute: () -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(initiallyExpanded) }
var showSpoilers by remember { mutableStateOf(!hideSpoilersByDefault) }
val expandedCategories = remember { mutableStateMapOf<ParentsGuideCategoryType, Boolean>() }
val title = stringResource(Res.string.parents_guide_title)
Column(modifier = modifier.fillMaxWidth().animateContentSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.sizeIn(minHeight = 48.dp)
.clickable(role = Role.Button) { expanded = !expanded }
.semantics { heading(); contentDescription = title }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Icon(
imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = stringResource(if (expanded) Res.string.parents_guide_collapse else Res.string.parents_guide_expand),
)
}
when (state) {
is ParentsGuideUiState.Available -> state.data.guide.overallSummary?.takeIf(String::isNotBlank)?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
is ParentsGuideUiState.Unavailable -> if (!expanded) {
Text(stringResource(Res.string.parents_guide_unavailable), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> Unit
}
AnimatedVisibility(expanded) {
when (state) {
ParentsGuideUiState.Loading -> LoadingGuide()
is ParentsGuideUiState.Available -> Column {
if (state.data.guide.overallStatus == ParentsGuideStatus.PARTIAL) {
Text(
stringResource(Res.string.parents_guide_partial),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
}
if (state.isSeriesFallback) {
Text(stringResource(Res.string.parents_guide_series_fallback), style = MaterialTheme.typography.labelMedium)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { showSpoilers = !showSpoilers }) {
Text(stringResource(if (showSpoilers) Res.string.parents_guide_hide_spoilers else Res.string.parents_guide_show_spoilers))
}
}
state.data.guide.categories.forEach { category ->
ParentsGuideCategoryRow(
category = category,
expanded = expandedCategories[category.category] == true,
showSpoilers = showSpoilers,
showTimestamps = showTimestamps,
onToggle = { expandedCategories[category.category] = expandedCategories[category.category] != true },
)
}
state.data.guide.sources.forEach { source ->
Text(
text = "${stringResource(Res.string.parents_guide_source)}: ${source.sourceName}${source.attributionText?.let { " — $it" }.orEmpty()}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
if (state.data.guide.categories.any { category -> category.scenes.any { it.startSeconds != null } }) {
Text(stringResource(Res.string.parents_guide_timestamp_disclaimer), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
}
}
is ParentsGuideUiState.Unavailable -> UnavailableGuide(onContribute)
is ParentsGuideUiState.Error -> Column(modifier = Modifier.padding(vertical = 8.dp)) {
Text(stringResource(Res.string.parents_guide_error), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
TextButton(onClick = onRetry) { Text(stringResource(Res.string.parents_guide_retry)) }
}
}
}
}
}
@Composable
fun ParentsGuideCategoryRow(
category: ParentsGuideCategory,
expanded: Boolean,
showSpoilers: Boolean,
showTimestamps: Boolean,
onToggle: () -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().sizeIn(minHeight = 48.dp).clickable(role = Role.Button, onClick = onToggle).padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(stringResource(category.category.labelResource()), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
SeverityBadge(category.severity)
Icon(if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, contentDescription = stringResource(if (expanded) Res.string.parents_guide_hide_scenes else Res.string.parents_guide_show_scenes))
}
AnimatedVisibility(expanded) {
Column(modifier = Modifier.padding(start = 12.dp, bottom = 8.dp)) {
category.summary?.takeIf(String::isNotBlank)?.let { Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 6.dp)) }
visibleScenes(category.scenes, showSpoilers).forEach { scene ->
ParentsGuideSceneItem(scene, showTimestamps)
}
if (!showSpoilers && category.scenes.any { it.spoilerLevel != ParentsGuideSpoilerLevel.NONE }) {
Text(stringResource(Res.string.parents_guide_spoiler_hidden), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp))
}
}
}
}
}
@Composable
fun ParentsGuideSceneItem(scene: ParentsGuideScene, showTimestamp: Boolean) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
if (showTimestamp && scene.startSeconds != null) {
Text(formatGuideTimestamp(scene.startSeconds), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold)
}
Text("${scene.description}", style = MaterialTheme.typography.bodySmall)
if (scene.verificationStatus == "moderator_verified") {
Text(stringResource(Res.string.parents_guide_verified), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
fun SeverityBadge(severity: ParentsGuideSeverity) {
val text = stringResource(severity.labelResource())
val container = when (severity) {
ParentsGuideSeverity.SEVERE -> MaterialTheme.colorScheme.errorContainer
ParentsGuideSeverity.MODERATE -> MaterialTheme.colorScheme.tertiaryContainer
ParentsGuideSeverity.MILD -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
Box(modifier = Modifier.padding(end = 6.dp).background(container, RoundedCornerShape(6.dp)).padding(horizontal = 8.dp, vertical = 3.dp)) {
Text(text, style = MaterialTheme.typography.labelMedium)
}
}
@Composable
private fun LoadingGuide() {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator(modifier = Modifier.sizeIn(maxWidth = 20.dp, maxHeight = 20.dp))
Text(stringResource(Res.string.parents_guide_loading), style = MaterialTheme.typography.bodySmall)
}
}
@Composable
private fun UnavailableGuide(onContribute: () -> Unit) {
Column(modifier = Modifier.padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(Res.string.parents_guide_unavailable), style = MaterialTheme.typography.bodySmall)
Button(onClick = onContribute) { Text(stringResource(Res.string.parents_guide_contribute)) }
}
}
private fun ParentsGuideCategoryType.labelResource(): StringResource = when (this) {
ParentsGuideCategoryType.SEX_NUDITY -> Res.string.parents_guide_sex_nudity
ParentsGuideCategoryType.VIOLENCE_GORE -> Res.string.parents_guide_violence_gore
ParentsGuideCategoryType.PROFANITY -> Res.string.parents_guide_profanity
ParentsGuideCategoryType.ALCOHOL_DRUGS_SMOKING -> Res.string.parents_guide_alcohol_drugs_smoking
ParentsGuideCategoryType.FRIGHTENING_INTENSE -> Res.string.parents_guide_frightening_intense
}
private fun ParentsGuideSeverity.labelResource(): StringResource = when (this) {
ParentsGuideSeverity.NONE -> Res.string.parents_guide_severity_none
ParentsGuideSeverity.MILD -> Res.string.parents_guide_severity_mild
ParentsGuideSeverity.MODERATE -> Res.string.parents_guide_severity_moderate
ParentsGuideSeverity.SEVERE -> Res.string.parents_guide_severity_severe
ParentsGuideSeverity.UNKNOWN -> Res.string.parents_guide_severity_unknown
}

View file

@ -0,0 +1,96 @@
package com.nuvio.app.features.parentsguide
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
class ParentsGuideRepositoryTest {
private val response = """
{"success":true,"data":{"identity":{"mediaType":"movie","title":"Test fixture","imdbId":"tt1234567"},"guide":{"id":"guide-1","overallStatus":"partial","contentVersion":1,"categories":[{"category":"profanity","severity":"severe","scenes":[{"description":"Test scene","startSeconds":862,"spoilerLevel":"major","verificationStatus":"moderator_verified"}]},{"category":"sex_nudity","severity":"none","scenes":[]}],"sources":[{"sourceType":"moderator_entry","sourceName":"Test fixture"}]}}}
""".trimIndent()
@Test
fun `json serialization preserves status timestamps and provenance`() {
val parsed = Json { ignoreUnknownKeys = true }.decodeFromString<ParentsGuideEnvelope>(response)
assertEquals(ParentsGuideStatus.PARTIAL, parsed.data.guide.overallStatus)
assertEquals(862, parsed.data.guide.categories[0].scenes[0].startSeconds)
assertEquals("Test fixture", parsed.data.guide.sources[0].sourceName)
}
@Test
fun `repository returns partial success and then a cache hit`() = runBlocking {
var calls = 0
val client = ParentsGuideClient(
ParentsGuideRemoteDataSource("https://example.test") { calls += 1; 200 to response },
ParentsGuideCache { 1_000L },
)
val request = ParentsGuideRequest("movie", imdbId = "tt1234567")
val first = assertIs<ParentsGuideUiState.Available>(client.load(request))
assertEquals(ParentsGuideStatus.PARTIAL, first.data.guide.overallStatus)
assertEquals(false, first.fromCache)
assertEquals(true, assertIs<ParentsGuideUiState.Available>(client.load(request)).fromCache)
assertEquals(1, calls)
}
@Test
fun `cache expires and refreshes`() = runBlocking {
var now = 0L
var calls = 0
val client = ParentsGuideClient(
ParentsGuideRemoteDataSource("https://example.test") { calls += 1; 200 to response },
ParentsGuideCache { now },
)
val request = ParentsGuideRequest("movie", imdbId = "tt1234567")
client.load(request)
now = 24 * 60 * 60 * 1_000L + 1
client.load(request)
assertEquals(2, calls)
}
@Test
fun `network error is isolated from details screen`() = runBlocking {
val client = ParentsGuideClient(
ParentsGuideRemoteDataSource("https://example.test") { 503 to "" },
ParentsGuideCache { 0L },
)
assertIs<ParentsGuideUiState.Error>(client.load(ParentsGuideRequest("movie", imdbId = "tt1234567")))
Unit
}
@Test
fun `identifier resolution supports imdb tmdb and episode shapes`() {
val imdb = resolveParentsGuideRequest("series", "tt1234567:2:4")!!
assertEquals("tt1234567", imdb.imdbId)
assertEquals(2, imdb.season)
assertEquals(4, imdb.episode)
assertEquals(42, resolveParentsGuideRequest("movie", "tmdb:42")?.tmdbId)
assertNull(resolveParentsGuideRequest("movie", ""))
}
@Test
fun `categories order and severity remain canonical`() {
val parsed = Json { ignoreUnknownKeys = true }.decodeFromString<ParentsGuideEnvelope>(response)
val ordered = orderedCategories(parsed.data.guide.categories)
assertEquals(listOf(ParentsGuideCategoryType.SEX_NUDITY, ParentsGuideCategoryType.PROFANITY), ordered.map { it.category })
assertEquals(ParentsGuideSeverity.SEVERE, parsed.data.guide.categories.first().severity)
}
@Test
fun `spoiler hiding only keeps non spoiler scenes`() {
val scenes = listOf(
ParentsGuideScene("Safe", spoilerLevel = ParentsGuideSpoilerLevel.NONE),
ParentsGuideScene("Hidden", spoilerLevel = ParentsGuideSpoilerLevel.MAJOR),
)
assertEquals(listOf("Safe"), visibleScenes(scenes, showSpoilers = false).map { it.description })
assertEquals(2, visibleScenes(scenes, showSpoilers = true).size)
}
@Test
fun `timestamp formatting supports long runtimes`() {
assertEquals("14:22", formatGuideTimestamp(862))
assertEquals("01:14:22", formatGuideTimestamp(4462))
}
}