From 7d42459aa333e41f86020e9bcaeb810ddb1d8920 Mon Sep 17 00:00:00 2001 From: Azif Musheer Date: Tue, 21 Jul 2026 11:48:39 +0530 Subject: [PATCH 1/5] feat: add native Parents Guide details section --- .gitignore | 3 + Docs/NUVIO_PULL_REQUEST.md | 49 ++++ composeApp/build.gradle.kts | 17 ++ .../composeResources/values/strings.xml | 30 +++ .../app/features/details/MetaDetailsScreen.kt | 90 ++++++- .../parentsguide/ParentsGuideModels.kt | 138 +++++++++++ .../parentsguide/ParentsGuidePreviews.kt | 90 +++++++ .../parentsguide/ParentsGuideRepository.kt | 155 ++++++++++++ .../parentsguide/ParentsGuideSection.kt | 225 ++++++++++++++++++ .../ParentsGuideRepositoryTest.kt | 96 ++++++++ 10 files changed, 885 insertions(+), 8 deletions(-) create mode 100644 Docs/NUVIO_PULL_REQUEST.md create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuidePreviews.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideSection.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideRepositoryTest.kt diff --git a/.gitignore b/.gitignore index 77f5bac83..64c0290eb 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ node_modules/ logs/ analysis/ Docs +!Docs/ +Docs/* +!Docs/NUVIO_PULL_REQUEST.md keystore/ scripts/build-distribution.sh asset diff --git a/Docs/NUVIO_PULL_REQUEST.md b/Docs/NUVIO_PULL_REQUEST.md new file mode 100644 index 000000000..5b30b4f76 --- /dev/null +++ b/Docs/NUVIO_PULL_REQUEST.md @@ -0,0 +1,49 @@ +# Add native Parents Guide details section + +## Problem + +Nuvio currently has a brief player-start content-warning overlay, but the details screen cannot show sourced category summaries or scene-level parental advisories before playback. + +## Proposed solution and placement + +Add a native, localized `ParentsGuideSection` to the details screen. The keyed lazy-list section is inserted immediately after Movie/Series Details and before More Like This; when users reorder or hide metadata sections it remains before recommendations. It is independent from stream and playback-source code. + +The top-level row and each category are independently expandable. The section supports loading, available, partial, unavailable, error/retry, stale cache, spoiler hiding, severity text, optional timestamps, source attribution, and a contribution action. Touch targets are at least 48 dp and screen-reader descriptions are provided. + +## Architecture + +- `ParentsGuideRemoteDataSource`: minimal HTTP GET using the existing multiplatform network primitive. +- `ParentsGuideCache`: successful results for 24 hours and unavailable results for one hour; stale successful data is returned when refresh fails. +- `ParentsGuideClient`/`ParentsGuideRepository`: request coalescing, identifier mapping, error isolation, and UI-state mapping. +- `ParentsGuideModels`: strict kotlinx.serialization wire model and canonical category/severity ordering. +- `ParentsGuideSection`: theme-derived Compose accordion components. +- `ParentsGuideConfig`: build-generated `PARENTS_GUIDE_API_BASE_URL`. It is blank by default until a production service is approved; local builds set it in `local.properties` or the environment. + +The Stremio v3 protocol has no standard Parents Guide resource. The companion addon publishes a valid no-stream manifest and a custom `/parentsguide/:type/:id.json` resource; this client calls the equivalent versioned API directly. + +## Privacy + +Only the current title's IMDb/TMDB/Stremio identifier, media type, optional season/episode, and language are sent. No viewing history, profile identity, library state, playback position, or stream information is transmitted. + +## Screenshots + +Required before opening the PR: collapsed, expanded categories, scene detail, partial, unavailable, error, dark/light, large text, and RTL screenshots. No screenshot is claimed in this preparation branch because a production API and emulator/device were not configured. + +## Tests performed + +- iOS simulator shared-source compilation: `./gradlew :composeApp:compileKotlinIosSimulatorArm64` +- iOS simulator common tests: `./gradlew :composeApp:iosSimulatorArm64Test` +- Android: `./gradlew :composeApp:assembleDebug -Pnuvio.android.distribution=playstore` (record final result before PR) + +Added tests cover JSON/status/provenance/timestamps, partial success, network error isolation, cache hit/expiry, identifier resolution, category order/severity, spoiler filtering, and timestamp formatting. + +## Compatibility and fallback + +The feature reuses the existing Show Parental Guide preference as its master enable switch. With the feature disabled, no request or section is added. With no configured endpoint or no guide, the normal details page remains functional and an unavailable state is shown. API errors are contained in the section and never fail metadata loading. Cached data can be displayed offline. + +## Known limitations + +- A stable production API URL and legal/operator contact must be approved before release. +- Cache is process-local in this first contribution; it supports transient offline use but not restoration after app restart. +- Episode requests include season/episode when encoded in the current ID. A future follow-up can surface an explicit episode selector and persist display preferences independently. +- Initial new strings are English and require community translation after merge. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b1b36fd33..30c82d90c 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -43,6 +43,9 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { @get:Input abstract val realtimeSyncEnabled: Property + @get:Input + abstract val parentsGuideApiBaseUrl: Property + @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,7 @@ val generateRuntimeConfigs = tasks.register("generat } ) realtimeSyncEnabled.set(runtimeConfigBoolean("NUVIO_REALTIME_SYNC_ENABLED", true)) + parentsGuideApiBaseUrl.set(runtimeConfigValue("PARENTS_GUIDE_API_BASE_URL")) } tasks.withType>().configureEach { diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1e20233ab..8189980e4 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -2057,4 +2057,34 @@ %d title %d titles + + Parents Guide + Sex & Nudity + Violence & Gore + Profanity + Alcohol, Drugs & Smoking + Frightening & Intense Scenes + None + Mild + Moderate + Severe + Unknown + Show scenes + Hide scenes + Hide spoilers + Show spoilers + Scene description hidden to avoid spoilers. + No detailed Parents Guide is available for this title yet. + Available information may be incomplete. + Showing general guidance for this series. + Contribute a guide + Retry + Source + Verified + Community submitted + Timestamp may vary by release version. + Loading Parents Guide… + Parents Guide could not be loaded. + Expand Parents Guide + Collapse Parents Guide diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 44911c48e..fb0293d06 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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.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(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() + 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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideModels.kt new file mode 100644 index 000000000..2348866d4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideModels.kt @@ -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 = emptyList(), + val sources: List = 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 = 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): List = + categories.sortedBy { categoryOrder[it.category] ?: Int.MAX_VALUE } + +internal fun visibleScenes(scenes: List, showSpoilers: Boolean): List = + 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')}" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuidePreviews.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuidePreviews.kt new file mode 100644 index 000000000..78b19d42a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuidePreviews.kt @@ -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 = {}) } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideRepository.kt new file mode 100644 index 000000000..672f2feb0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideRepository.kt @@ -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() + + 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 = { 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(body).data + } +} + +internal class ParentsGuideClient( + private val remote: ParentsGuideRemoteDataSource, + private val cache: ParentsGuideCache, +) { + private val locks = mutableMapOf() + + 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')) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideSection.kt new file mode 100644 index 000000000..b9459a056 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideSection.kt @@ -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() } + 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 +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideRepositoryTest.kt new file mode 100644 index 000000000..aa009f63d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/parentsguide/ParentsGuideRepositoryTest.kt @@ -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(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(client.load(request)) + assertEquals(ParentsGuideStatus.PARTIAL, first.data.guide.overallStatus) + assertEquals(false, first.fromCache) + assertEquals(true, assertIs(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(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(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)) + } +} From cf1c6b70ffc65442d6f9ded5397ec3866239593f Mon Sep 17 00:00:00 2001 From: Azif Musheer Date: Tue, 21 Jul 2026 12:44:12 +0530 Subject: [PATCH 2/5] feat: use production parents guide API --- Docs/NUVIO_PULL_REQUEST.md | 2 +- composeApp/build.gradle.kts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Docs/NUVIO_PULL_REQUEST.md b/Docs/NUVIO_PULL_REQUEST.md index 5b30b4f76..7bc59f547 100644 --- a/Docs/NUVIO_PULL_REQUEST.md +++ b/Docs/NUVIO_PULL_REQUEST.md @@ -17,7 +17,7 @@ The top-level row and each category are independently expandable. The section su - `ParentsGuideClient`/`ParentsGuideRepository`: request coalescing, identifier mapping, error isolation, and UI-state mapping. - `ParentsGuideModels`: strict kotlinx.serialization wire model and canonical category/severity ordering. - `ParentsGuideSection`: theme-derived Compose accordion components. -- `ParentsGuideConfig`: build-generated `PARENTS_GUIDE_API_BASE_URL`. It is blank by default until a production service is approved; local builds set it in `local.properties` or the environment. +- `ParentsGuideConfig`: build-generated `PARENTS_GUIDE_API_BASE_URL`. It defaults to the production service at `https://nuvio-parents-guide-addon.vercel.app`; local builds can override it in `local.properties` or the environment. The Stremio v3 protocol has no standard Parents Guide resource. The companion addon publishes a valid no-stream manifest and a custom `/parentsguide/:type/:id.json` resource; this client calls the equivalent versioned API directly. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 30c82d90c..c073ab1c1 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -317,7 +317,12 @@ val generateRuntimeConfigs = tasks.register("generat } ) realtimeSyncEnabled.set(runtimeConfigBoolean("NUVIO_REALTIME_SYNC_ENABLED", true)) - parentsGuideApiBaseUrl.set(runtimeConfigValue("PARENTS_GUIDE_API_BASE_URL")) + parentsGuideApiBaseUrl.set( + runtimeConfigValue( + "PARENTS_GUIDE_API_BASE_URL", + "https://nuvio-parents-guide-addon.vercel.app", + ) + ) } tasks.withType>().configureEach { From a0db889b04e845e7eb2a91fa97ae743c9eb2c5aa Mon Sep 17 00:00:00 2001 From: Azif Musheer Date: Tue, 21 Jul 2026 12:45:27 +0530 Subject: [PATCH 3/5] docs: update production handoff status --- Docs/NUVIO_PULL_REQUEST.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Docs/NUVIO_PULL_REQUEST.md b/Docs/NUVIO_PULL_REQUEST.md index 7bc59f547..92091e844 100644 --- a/Docs/NUVIO_PULL_REQUEST.md +++ b/Docs/NUVIO_PULL_REQUEST.md @@ -27,13 +27,13 @@ Only the current title's IMDb/TMDB/Stremio identifier, media type, optional seas ## Screenshots -Required before opening the PR: collapsed, expanded categories, scene detail, partial, unavailable, error, dark/light, large text, and RTL screenshots. No screenshot is claimed in this preparation branch because a production API and emulator/device were not configured. +Required before marking the draft PR ready for review: collapsed, expanded categories, scene detail, partial, unavailable, error, dark/light, large text, and RTL screenshots. No screenshot is claimed because a working simulator/device was not available in this environment. ## Tests performed -- iOS simulator shared-source compilation: `./gradlew :composeApp:compileKotlinIosSimulatorArm64` -- iOS simulator common tests: `./gradlew :composeApp:iosSimulatorArm64Test` -- Android: `./gradlew :composeApp:assembleDebug -Pnuvio.android.distribution=playstore` (record final result before PR) +- Passed iOS simulator shared-source compilation: `./gradlew :composeApp:compileKotlinIosSimulatorArm64` +- iOS simulator tests compiled and linked, but execution could not be completed because the simulator service hung. +- Android was not verified because the Android SDK is unavailable in this environment. Added tests cover JSON/status/provenance/timestamps, partial success, network error isolation, cache hit/expiry, identifier resolution, category order/severity, spoiler filtering, and timestamp formatting. @@ -43,7 +43,6 @@ The feature reuses the existing Show Parental Guide preference as its master ena ## Known limitations -- A stable production API URL and legal/operator contact must be approved before release. - Cache is process-local in this first contribution; it supports transient offline use but not restoration after app restart. - Episode requests include season/episode when encoded in the current ID. A future follow-up can surface an explicit episode selector and persist display preferences independently. - Initial new strings are English and require community translation after merge. From 4b28bc41dc015f0df9d3aac88f392a19691533b5 Mon Sep 17 00:00:00 2001 From: Azif Musheer Date: Tue, 21 Jul 2026 12:56:15 +0530 Subject: [PATCH 4/5] docs: align parents guide PR with contribution policy --- Docs/NUVIO_PULL_REQUEST.md | 86 +++++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 29 deletions(-) diff --git a/Docs/NUVIO_PULL_REQUEST.md b/Docs/NUVIO_PULL_REQUEST.md index 92091e844..ee31acda2 100644 --- a/Docs/NUVIO_PULL_REQUEST.md +++ b/Docs/NUVIO_PULL_REQUEST.md @@ -1,48 +1,76 @@ -# Add native Parents Guide details section +## Summary -## Problem +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. -Nuvio currently has a brief player-start content-warning overlay, but the details screen cannot show sourced category summaries or scene-level parental advisories before playback. +## PR type -## Proposed solution and placement +- [ ] 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 -Add a native, localized `ParentsGuideSection` to the details screen. The keyed lazy-list section is inserted immediately after Movie/Series Details and before More Like This; when users reorder or hide metadata sections it remains before recommendations. It is independent from stream and playback-source code. +This remains unchecked while the feature request is awaiting explicit maintainer approval. -The top-level row and each category are independently expandable. The section supports loading, available, partial, unavailable, error/retry, stale cache, spoiler hiding, severity text, optional timestamps, source attribution, and a contribution action. Touch targets are at least 48 dp and screen-reader descriptions are provided. +## Why -## Architecture +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. -- `ParentsGuideRemoteDataSource`: minimal HTTP GET using the existing multiplatform network primitive. -- `ParentsGuideCache`: successful results for 24 hours and unavailable results for one hour; stale successful data is returned when refresh fails. -- `ParentsGuideClient`/`ParentsGuideRepository`: request coalescing, identifier mapping, error isolation, and UI-state mapping. -- `ParentsGuideModels`: strict kotlinx.serialization wire model and canonical category/severity ordering. -- `ParentsGuideSection`: theme-derived Compose accordion components. -- `ParentsGuideConfig`: build-generated `PARENTS_GUIDE_API_BASE_URL`. It defaults to the production service at `https://nuvio-parents-guide-addon.vercel.app`; local builds can override it in `local.properties` or the environment. +## Issue or approval -The Stremio v3 protocol has no standard Parents Guide resource. The companion addon publishes a valid no-stream manifest and a custom `/parentsguide/:type/:id.json` resource; this client calls the equivalent versioned API directly. +Awaiting a maintainer decision in #1600. This PR is intentionally a draft and is not submitted as review-ready before approval. -## Privacy +## UI / behavior impact -Only the current title's IMDb/TMDB/Stremio identifier, media type, optional season/episode, and language are sent. No viewing history, profile identity, library state, playback position, or stream information is transmitted. +- [ ] 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 -## Screenshots +The implementation adds UI and network/cache behavior. The approval boxes will remain unchecked unless maintainers approve #1600. -Required before marking the draft PR ready for review: collapsed, expanded categories, scene detail, partial, unavailable, error, dark/light, large text, and RTL screenshots. No screenshot is claimed because a working simulator/device was not available in this environment. +## Policy check -## Tests performed +- [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. -- Passed iOS simulator shared-source compilation: `./gradlew :composeApp:compileKotlinIosSimulatorArm64` -- iOS simulator tests compiled and linked, but execution could not be completed because the simulator service hung. -- Android was not verified because the Android SDK is unavailable in this environment. +The approval-dependent checks will be completed only after explicit approval and visual proof. -Added tests cover JSON/status/provenance/timestamps, partial success, network error isolation, cache hit/expiry, identifier resolution, category order/severity, spoiler filtering, and timestamp formatting. +## Scope boundaries -## Compatibility and fallback +- 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. -The feature reuses the existing Show Parental Guide preference as its master enable switch. With the feature disabled, no request or section is added. With no configured endpoint or no guide, the normal details page remains functional and an unavailable state is shown. API errors are contained in the section and never fail metadata loading. Cached data can be displayed offline. +## Testing -## Known limitations +- 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. -- Cache is process-local in this first contribution; it supports transient offline use but not restoration after app restart. -- Episode requests include season/episode when encoded in the current ID. A future follow-up can surface an explicit episode selector and persist display preferences independently. -- Initial new strings are English and require community translation after merge. +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 + +#1600 — awaiting explicit maintainer approval. From 401f160720c9b23f2099dc15cab838e0c57a43d4 Mon Sep 17 00:00:00 2001 From: Azif Musheer Date: Tue, 21 Jul 2026 13:02:53 +0530 Subject: [PATCH 5/5] docs: link labeled feature request --- Docs/NUVIO_PULL_REQUEST.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Docs/NUVIO_PULL_REQUEST.md b/Docs/NUVIO_PULL_REQUEST.md index ee31acda2..feb99278b 100644 --- a/Docs/NUVIO_PULL_REQUEST.md +++ b/Docs/NUVIO_PULL_REQUEST.md @@ -20,7 +20,7 @@ Families cannot currently review sourced category summaries or scene-level paren ## Issue or approval -Awaiting a maintainer decision in #1600. This PR is intentionally a draft and is not submitted as review-ready before 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 @@ -31,7 +31,7 @@ Awaiting a maintainer decision in #1600. This PR is intentionally a draft and is - [ ] 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 #1600. +The implementation adds UI and network/cache behavior. The approval boxes will remain unchecked unless maintainers approve #1601. ## Policy check @@ -73,4 +73,4 @@ None. With the feature disabled, no request or section is added. Missing guides ## Linked issues -#1600 — awaiting explicit maintainer approval. +#1601 — awaiting explicit maintainer approval.