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..feb99278b --- /dev/null +++ b/Docs/NUVIO_PULL_REQUEST.md @@ -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. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b1b36fd33..c073ab1c1 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,12 @@ val generateRuntimeConfigs = tasks.register("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>().configureEach { diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f00ffc65d..85d3bf6b9 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -2058,4 +2058,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)) + } +}