From 56c1167382ca53f66bf80bf785a2da27f521fb8d Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Mon, 10 Aug 2026 20:24:22 +1200 Subject: [PATCH] 0.2.45 - Advanced analytics, logout old versions --- CLAUDE.md | 17 + NOTICE | 5 - app/build.gradle.kts | 24 +- app/proguard-rules.pro | 16 +- app/src/main/AndroidManifest.xml | 10 - .../memby/data/EmbyRepository.kt | 32 ++ .../memby/data/analytics/JourneyAnalytics.kt | 80 +++++ .../memby/data/model/GatewayModels.kt | 21 ++ .../memby/data/remote/GatewayApi.kt | 5 + .../ponzischeme89/memby/ui/HomeViewModel.kt | 21 ++ .../ponzischeme89/memby/ui/MainActivity.kt | 125 ++++++- .../memby/ui/player/MpvFallbackActivity.kt | 327 ------------------ .../memby/ui/player/PlaybackRecovery.kt | 8 - .../memby/ui/player/PlayerActivity.kt | 37 +- .../memby/ui/search/SearchScreen.kt | 10 + .../memby/ui/settings/SettingsSheet.kt | 25 +- .../data/analytics/JourneyAnalyticsTest.kt | 38 ++ .../memby/ui/player/PlaybackRecoveryTest.kt | 20 -- server/README.md | 8 + server/cmd/memby-server/main.go | 4 +- server/internal/api/admin.go | 24 +- .../internal/api/admin/pages/engagement.html | 55 ++- server/internal/api/admin/pages/engagement.js | 84 ++++- server/internal/api/analytics.go | 114 +++++- server/internal/api/analytics_test.go | 38 ++ server/internal/api/api.go | 42 ++- server/internal/api/update.go | 53 ++- server/internal/api/update_test.go | 105 ++++++ server/internal/config/config.go | 6 +- server/internal/config/config_test.go | 14 + server/internal/store/analytics.go | 181 +++++++++- server/internal/store/schema.sql | 28 ++ 32 files changed, 1125 insertions(+), 452 deletions(-) create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyAnalytics.kt delete mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/player/MpvFallbackActivity.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/data/analytics/JourneyAnalyticsTest.kt create mode 100644 server/internal/api/analytics_test.go diff --git a/CLAUDE.md b/CLAUDE.md index dfaa46d..5802917 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1522,6 +1522,23 @@ listing packages, because the previous rules named `com.mattcohen.embyscreensave and had silently matched nothing since v0.1.53. Lint still has `abortOnError = false`, so R8 warnings do not fail the build — check the task output after changing dependencies. +**R8 shrinks Kotlin; nothing shrinks a `.so`.** The APK is dominated by whatever native +code it carries, and native code is packaged **uncompressed** here (`minSdk` 23 means +`extractNativeLibs=false`), once per ABI. `io.github.abdallahmehiz:mpv-android-lib` was +added in v0.2.40 as a last-resort software video fallback and took the release APK from +3.1MB to **168MB**: a whole FFmpeg, `libc++_shared` and a 6MB subtitle font, times four +ABIs, for a path almost nobody ever reaches. It has been removed, along with +`MpvFallbackActivity`, `shouldUseLibmpvFallback` and the `is.xyz.mpv` keep rule. Two things +came out of it and both are cheap to keep: + +- **`defaultConfig.ndk.abiFilters` is `arm64-v8a` + `armeabi-v7a`.** Android TV is ARM; the + x86 slices only ever served the emulator, which is not how this app is tested. +- **A new native dependency is a size decision, not a dependency decision.** Check what it + weighs across both ABIs before adding it — the Jellyfin FFmpeg *audio* decoder that + actually delivers DTS is 1.5MB per ABI because it links only the decoders it needs, which + is the shape to look for. Anything on mpv's scale belongs behind a separately downloaded + split, not in the base APK that every television sideloads on every update. + **Baseline profile.** `androidx.profileinstaller` plus a profile generated by `benchmark/BaselineProfileGenerator.kt`. Regenerate against a real television with `.\gradlew.bat :app:generateReleaseBaselineProfile`; the result is checked in under diff --git a/NOTICE b/NOTICE index 1a00591..74d5bac 100644 --- a/NOTICE +++ b/NOTICE @@ -55,11 +55,6 @@ The Media3 software audio fallback uses Jellyfin's FFmpeg decoder extension: https://github.com/jellyfin/jellyfin-androidx-media -The compatibility playback path uses mpv-android-lib, an Android libmpv -wrapper distributed under the MIT License: - -https://github.com/abdallahmehiz/mpv-android - Memby is an independent project and is not affiliated with or endorsed by Emby LLC. Emby is a trademark of Emby LLC. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7f18ad6..a2ee985 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.43" +val defaultVersionName = "0.2.45" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() @@ -79,6 +79,15 @@ android { versionCode = membyVersionCode versionName = membyVersionName + // Every native library in this APK is packaged uncompressed (minSdk 23 means + // extractNativeLibs=false), so an ABI nobody runs is dead weight carried through + // every sideload. Android TV is ARM: the x86 slices existed only for the emulator, + // which is not how this app is ever tested. Removing them is not a compatibility + // decision to revisit — adding a native dependency is. + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a") + } + buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"") buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"") buildConfigField("String", "SOURCE_CODE_URL", buildConfigString(membySourceUrl)) @@ -237,13 +246,16 @@ dependencies { // surround track that is not bitstreamed is decoded to PCM instead of making the // server re-encode the video beside it. This build targets Media3 1.5.x; Jellyfin's // 1.5.0 extension is the matching published binary for that line. + // + // This is the ONLY native dependency the app carries, and it is 1.5MB per ABI because + // it links just the audio decoders it needs. It replaced a libmpv software-decoding + // fallback (io.github.abdallahmehiz:mpv-android-lib) that shipped a whole FFmpeg, + // libc++ and a 6MB subtitle font — 155MB of native code across four ABIs, which took + // the release APK from 3.1MB to 168MB and made every sideload a several-minute + // affair. A last-resort video path is not worth fifty times the app. If one is wanted + // again, it belongs behind a separately downloaded split, not in the base APK. implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.5.0+1") - // Software-decoding fallback after Media3 exhausts its codec/container recovery. - // Kept out of the normal path: Android TV still gets hardware decode, passthrough, - // the full Memby OSD and the pre-roll from Media3 whenever the device can play it. - implementation("io.github.abdallahmehiz:mpv-android-lib:0.1.12") - debugImplementation("androidx.compose.ui:ui-tooling") // Ahead-of-time compiles the startup + first-scroll path. Regenerate against a real diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 72c9d61..9af1201 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -34,15 +34,21 @@ -keep,allowobfuscation interface com.ponzischeme89.memby.data.remote.GatewayApi -keepattributes Exceptions +# --- Room (WorkManager's database) --------------------------------------------------- +# Room instantiates its generated `_Impl` with getDeclaredConstructor(), so the +# constructor is reachable only by reflection. R8 from AGP 9 removes it while keeping the +# class, and the failure is total: WorkManager builds its database from androidx.startup's +# ContentProvider, so the app dies in handleBindApplication with +# `NoSuchMethodException: androidx.work.impl.WorkDatabase_Impl. []` before any Memby +# code runs — a launcher icon that does nothing, with no Memby frame ever drawn. Room's own +# consumer rules do not cover this on AGP 9. Written as `extends RoomDatabase` rather than +# naming WorkDatabase so a future Room database here is covered too. +-keep class * extends androidx.room.RoomDatabase { (); } + # --- Media3 / coroutines ------------------------------------------------------------- -dontwarn androidx.media3.** -dontwarn kotlinx.coroutines.** -# --- libmpv JNI ---------------------------------------------------------------------- -# The published wrapper has no consumer rules. Its native bridge looks these classes and -# callbacks up by their compiled names, so a release build must not rename either side. --keep class is.xyz.mpv.** { *; } - # --- Crash readability --------------------------------------------------------------- # Releases are self-hosted with no crash reporter, so a stack trace read off a TV over # adb is the only diagnostic there is. Line numbers cost a little dex size and are worth diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5cc24ea..323ff76 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -117,16 +117,6 @@ android:theme="@style/Theme.Memby.Fullscreen" tools:ignore="DiscouragedApi" /> - - - ) { + if (!ServerConfig.isGateway || events.isEmpty() || snapshot.token.isNullOrBlank()) return + // The server also verifies this guard. It prevents an old profile's buffered + // events being attributed to the new profile during the handover frame. + val currentUser = snapshot.userId ?: return + val matching = events.filter { it.userId == currentUser } + if (matching.isEmpty()) return + scope.launch { runCatching { requireGateway().reportJourneyEvents(GatewayJourneyEvents(matching)) } } + } + + /** + * Flushes both analytics streams before a profile token is replaced. Unlike the + * periodic fire-and-forget path, this completes under the outgoing user's bearer token. + */ + suspend fun reportAnalyticsBeforeProfileSwitch( + rowEvents: List, + journeyEvents: List, + ) { + if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) return + val currentUser = snapshot.userId ?: return + val matchingJourneys = journeyEvents.filter { it.userId == currentUser } + runCatching { + if (rowEvents.isNotEmpty()) requireGateway().reportRowEvents(GatewayRowEvents(rowEvents)) + if (matchingJourneys.isNotEmpty()) { + requireGateway().reportJourneyEvents(GatewayJourneyEvents(matchingJourneys)) + } + } + } + /** * Optional decoration for the player pre-roll. Failure or direct-to-Emby mode returns * an empty schedule immediately; playback never depends on this request. diff --git a/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyAnalytics.kt b/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyAnalytics.kt new file mode 100644 index 0000000..21175dd --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyAnalytics.kt @@ -0,0 +1,80 @@ +package com.ponzischeme89.memby.data.analytics + +import com.ponzischeme89.memby.data.model.GatewayJourneyEvent +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID + +/** + * Builds one ordered, user-scoped foreground journey. Callers provide controlled labels; + * this collector has no field for titles, queries, setting values or arbitrary metadata. + */ +class JourneyAnalytics( + private val userId: String, + private val now: () -> Long = System::currentTimeMillis, + private val journeyId: String = UUID.randomUUID().toString(), +) { + private val lock = Any() + private val buffer = ArrayList() + private var sequence = 0 + private var ended = false + + init { track("session", "journey_start", screen = "home", feature = "app") } + + fun track( + category: String, + action: String, + screen: String = "", + feature: String = "", + source: String = "", + target: String = "", + itemId: String = "", + itemType: String = "", + outcome: String = "", + ) = synchronized(lock) { + if (ended) return@synchronized + buffer += GatewayJourneyEvent( + userId = userId, + journeyId = journeyId, + sequence = sequence++, + category = category, + action = action, + screen = clean(screen), + feature = clean(feature), + source = clean(source), + target = clean(target), + itemId = clean(itemId), + itemType = clean(itemType), + outcome = clean(outcome), + occurredAt = timestamp(), + ) + if (buffer.size > 200) buffer.removeAt(0) + } + + fun end(screen: String) = synchronized(lock) { + if (!ended) { + track("session", "journey_end", screen = screen, feature = "app") + ended = true + } + } + + fun drain(): List = synchronized(lock) { + val copy = buffer.toList(); buffer.clear(); copy + } + + private fun clean(value: String): String = value.take(100).filter { + it.isLetterOrDigit() || it == '-' || it == '_' || it == '.' || it == ':' + } + + private fun timestamp(): String = iso8601.get()!!.format(Date(now())) + + companion object { + private val iso8601 = object : ThreadLocal() { + override fun initialValue() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index edbb2b9..0ad252b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -720,6 +720,27 @@ data class GatewayRowEvents( val events: List, ) +/** One privacy-bounded step in an authenticated user's app journey. */ +@Serializable +data class GatewayJourneyEvent( + val userId: String, + val journeyId: String, + val sequence: Int, + val category: String, + val action: String, + val screen: String = "", + val feature: String = "", + val source: String = "", + val target: String = "", + val itemId: String = "", + val itemType: String = "", + val outcome: String = "", + val occurredAt: String = "", +) + +@Serializable +data class GatewayJourneyEvents(val events: List) + @Serializable data class GatewayPlaybackReport( val itemId: String, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index f11642a..706836e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -17,6 +17,7 @@ import com.ponzischeme89.memby.data.model.GatewayPlayback import com.ponzischeme89.memby.data.model.GatewayPlaybackReport import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule import com.ponzischeme89.memby.data.model.GatewayRowEvents +import com.ponzischeme89.memby.data.model.GatewayJourneyEvents import com.ponzischeme89.memby.data.model.GatewayRows import com.ponzischeme89.memby.data.model.GatewaySearchHistory import com.ponzischeme89.memby.data.model.GatewayRequestLookup @@ -297,4 +298,8 @@ interface GatewayApi { /** Row engagement, uploaded in batches. Fire-and-forget: failures are not retried. */ @POST("v1/analytics/rows") suspend fun reportRowEvents(@Body body: GatewayRowEvents) + + /** Significant per-user journey steps. Fire-and-forget like row engagement. */ + @POST("v1/analytics/events") + suspend fun reportJourneyEvents(@Body body: GatewayJourneyEvents) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 9f35116..465a3c6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -7,6 +7,7 @@ import com.ponzischeme89.memby.data.EmbyRepository import com.ponzischeme89.memby.data.HomeCache import com.ponzischeme89.memby.data.HomeSnapshot import com.ponzischeme89.memby.data.analytics.RowAnalytics +import com.ponzischeme89.memby.data.analytics.JourneyAnalytics import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.isMaintenanceError import com.ponzischeme89.memby.data.model.BaseItem @@ -161,6 +162,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { /** Row engagement, buffered here and uploaded in batches. */ private val analytics = RowAnalytics() + private val journey = JourneyAnalytics(repository.currentSettings.userId.orEmpty()) init { refreshAll() @@ -193,6 +195,25 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { fun flushAnalytics() { analytics.endFocus() repository.reportRowEvents(analytics.drain()) + repository.reportJourneyEvents(journey.drain()) + } + + fun trackJourney( + category: String, action: String, screen: String = "", feature: String = "", + source: String = "", target: String = "", itemId: String = "", itemType: String = "", + outcome: String = "", + ) = journey.track(category, action, screen, feature, source, target, itemId, itemType, outcome) + + fun endJourney(screen: String) { journey.end(screen); flushAnalytics() } + + suspend fun endJourneyBeforeProfileSwitch(screen: String) { + analytics.endFocus() + journey.track( + category = "profile", action = "switch", screen = screen, + feature = "profiles", source = screen, target = "profiles", + ) + journey.end(screen) + repository.reportAnalyticsBeforeProfileSwitch(analytics.drain(), journey.drain()) } fun loadForYou(availableMinutes: Int = _forYou.value.availableMinutes) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 681a0ae..940dfb7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -51,6 +51,7 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.setValue @@ -1829,6 +1830,10 @@ private fun HomeScreen( val playbackLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult(), ) { + homeViewModel.trackJourney( + category = "playback", action = "stop", screen = "player", + feature = "playback", target = selectedDestination.name.lowercase(), + ) // PlayerActivity has finished and this activity owns the window again. Compose // needs one frame to reattach the saved card's focus node before it can receive // focus, especially when playback progress refreshed the row behind the player. @@ -1869,6 +1874,12 @@ private fun HomeScreen( } val playItem: (BaseItem) -> Unit = playItem@{ item -> if (launchingItem != null || !item.membyPlayable) return@playItem + homeViewModel.trackJourney( + category = "playback", action = "request", screen = selectedDestination.name.lowercase(), + feature = "playback", source = returnRowId.orEmpty(), target = "player", + itemId = item.id, itemType = item.type, + ) + homeViewModel.flushAnalytics() launchingItem = item val playbackRequestedAtMs = SystemClock.elapsedRealtime() // Resuming: open the player now and let it resolve the stream while it starts. @@ -1981,6 +1992,22 @@ private fun HomeScreen( repo.markForYouOpened() } } + val journeyScreen = when { + showSettings -> "settings" + showNotifications -> "notifications" + userSwitcherVisible || showProfiles -> "profiles" + detailsItem != null -> "details" + selectedMyShow != null -> "my_show_details" + genreBrowseItemType != null -> "genre_browser" + else -> selectedDestination.name.lowercase() + } + LaunchedEffect(journeyScreen) { + homeViewModel.trackJourney( + category = "navigation", action = "screen_view", screen = journeyScreen, + feature = journeyScreen, target = journeyScreen, + ) + } + val latestJourneyScreen by rememberUpdatedState(journeyScreen) LaunchedEffect(showForYouNudge, selectedDestination, settings.hasOpenedForYou) { if ( showForYouNudge && @@ -2018,7 +2045,7 @@ private fun HomeScreen( lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) - homeViewModel.flushAnalytics() + homeViewModel.endJourney(latestJourneyScreen) } } @@ -2037,6 +2064,11 @@ private fun HomeScreen( navigationExpanded = it }, onDestinationSelected = { destination -> + homeViewModel.trackJourney( + category = "navigation", action = "select", + screen = journeyScreen, feature = destination.name.lowercase(), + source = journeyScreen, target = destination.name.lowercase(), + ) genreBrowseItemType = null when (destination) { BrowseDestination.SETTINGS -> { @@ -2136,6 +2168,12 @@ private fun HomeScreen( returnFocusRequester = cardReturnFocusRequester, initialQuery = initialSearchQuery, onInitialQueryConsumed = { initialSearchQuery = null }, + onSearchStarted = { + homeViewModel.trackJourney( + category = "search", action = "start", screen = "search", + feature = "search", source = "search", target = "search_results", + ) + }, onItemFocused = homeViewModel::focusItem, onItemSelected = { item -> returnRowId = SEARCH_ROW_ID @@ -2143,6 +2181,11 @@ private fun HomeScreen( destinationFocus[BrowseDestination.SEARCH] = SEARCH_ROW_ID to item.id homeViewModel.focusItem(item) if (item.membyPlayable) { + homeViewModel.trackJourney( + category = "content", action = "open", screen = "search", + feature = "search", source = "search_results", target = "details", + itemId = item.id, itemType = item.type, + ) detailsAiringNotice = null detailsItem = item } @@ -2175,6 +2218,11 @@ private fun HomeScreen( returnRowId = GENRE_BROWSER_ROW_ID returnItemId = item.id homeViewModel.focusItem(item) + homeViewModel.trackJourney( + category = "content", action = "open", screen = "genre_browser", + feature = "genre_browse", source = "genre_results", target = "details", + itemId = item.id, itemType = item.type, + ) detailsAiringNotice = null detailsItem = item }, @@ -2335,6 +2383,11 @@ private fun HomeScreen( entryFocusRequester = contentFocusRequester, onFocused = { navigationExpanded = false }, onOpenCategory = { categoryId -> + homeViewModel.trackJourney( + category = "navigation", action = "open", + screen = selectedDestination.name.lowercase(), feature = "genre_browse", + source = "genre_strip", target = "genre_browser", + ) genreBrowseInitialCategoryId = categoryId genreBrowseItemType = itemType }, @@ -2355,7 +2408,14 @@ private fun HomeScreen( contentFocusRequester = contentFocusRequester.takeUnless { genreBrowserEnabled }, - onShowSelected = { selectedMyShow = it }, + onShowSelected = { + homeViewModel.trackJourney( + category = "content", action = "open", screen = "shows", + feature = "my_shows", source = "my_shows", target = "my_show_details", + itemId = it.itemId, + ) + selectedMyShow = it + }, onContentFocused = { navigationExpanded = false }, ) } @@ -2370,6 +2430,10 @@ private fun HomeScreen( navigationFocusRequester = navigationFocusRequester, onContentFocused = { navigationExpanded = false }, onQuerySelected = { query -> + homeViewModel.trackJourney( + category = "search", action = "select", screen = "favourites", + feature = "recent_searches", source = "recent_searches", target = "search", + ) initialSearchQuery = query selectedDestination = BrowseDestination.SEARCH }, @@ -2383,6 +2447,10 @@ private fun HomeScreen( loading = forYouState.loading, error = forYouState.error, onSelected = { minutes -> + homeViewModel.trackJourney( + category = "recommendations", action = "change", screen = "for_you", + feature = "for_you_time", outcome = "success", + ) homeViewModel.loadForYou(minutes) scope.launch { repo.setForYouMinutes(minutes) } }, @@ -2499,6 +2567,12 @@ private fun HomeScreen( returnRowId = row.id returnItemId = item.id homeViewModel.trackRowSelected(row.id, row.kind.name, item.id) + homeViewModel.trackJourney( + category = "content", action = "open", + screen = selectedDestination.name.lowercase(), feature = row.kind.name.lowercase(), + source = row.id, target = if (item.membyPlayable) "details" else "content_action", + itemId = item.id, itemType = item.type, + ) // A schedule card is an episode that has not aired, so // it is not playable and has no page of its own. What // the viewer asked for is the show — carrying the air @@ -2577,6 +2651,7 @@ private fun HomeScreen( if (profile.id != settings.activeProfileId) { switchingProfileId = profile.id scope.launch { + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) runCatching { repo.switchProfile(profile) } .onFailure { switchingProfileId = null } } @@ -2594,6 +2669,10 @@ private fun HomeScreen( }, alertCount = notificationState.notifications.size, onOpenAlerts = { + homeViewModel.trackJourney( + category = "notifications", action = "open", screen = journeyScreen, + feature = "notifications", target = "notifications", + ) userSwitcherVisible = false navigationExpanded = false showNotifications = true @@ -2653,6 +2732,12 @@ private fun HomeScreen( onClose = closeSettings, overlay = false, navigationFocusRequester = navigationFocusRequester, + onAnalyticsEvent = { feature, action -> + homeViewModel.trackJourney( + category = "settings", action = action, screen = "settings", + feature = feature, outcome = "success", + ) + }, modifier = Modifier .weight(1f) .offset { IntOffset(x = settingsShift.roundToPx(), y = 0) }, @@ -2672,6 +2757,7 @@ private fun HomeScreen( } else { switchingProfileId = profile.id scope.launch { + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) runCatching { repo.switchProfile(profile) } .onFailure { switchingProfileId = null } } @@ -2684,7 +2770,12 @@ private fun HomeScreen( removingProfileId = null } }, - onAddProfile = { addingProfile = true }, + onAddProfile = { + scope.launch { + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) + addingProfile = true + } + }, onClose = { showProfiles = false }, ) } @@ -2714,6 +2805,11 @@ private fun HomeScreen( selected = selected, airingNotice = detailsAiringNotice, onOpenItem = { related -> + homeViewModel.trackJourney( + category = "recommendations", action = "open", screen = "details", + feature = "related", source = "related", target = "details", + itemId = related.id, itemType = related.type, + ) detailsTrail = detailsTrail + selected // Ask for the full record the same way a focused card does. Some // routes here hand over a stub rather than a row item — the episode @@ -2733,9 +2829,21 @@ private fun HomeScreen( detailsAiringNotice = null playItem(it) }, - onToggleFavorite = homeViewModel::setFavorite, + onToggleFavorite = { item, saved -> + homeViewModel.trackJourney( + category = "library", action = if (saved) "favourite" else "unfavourite", + screen = "details", feature = "favorites", itemId = item.id, itemType = item.type, + outcome = "success", + ) + homeViewModel.setFavorite(item, saved) + }, isMyShow = myShows.any { it.itemId == selected.id }, onToggleMyShow = { item, saved -> + homeViewModel.trackJourney( + category = "library", action = if (saved) "follow" else "unfollow", + screen = "details", feature = "my_shows", itemId = item.id, itemType = item.type, + outcome = "success", + ) // Optimistic, the way a favourite already is. Following a show is a // press with an obvious outcome, and the server's answer to it is the // *whole* list decorated with Sonarr's lifecycle for every show on it @@ -2766,7 +2874,14 @@ private fun HomeScreen( } } }, - onTogglePlayed = homeViewModel::setPlayed, + onTogglePlayed = { item, played -> + homeViewModel.trackJourney( + category = "library", action = if (played) "mark_played" else "mark_unplayed", + screen = "details", feature = "played_status", itemId = item.id, itemType = item.type, + outcome = "success", + ) + homeViewModel.setPlayed(item, played) + }, onClose = { detailsItem = null detailsTrail = emptyList() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/MpvFallbackActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/MpvFallbackActivity.kt deleted file mode 100644 index 4e2e546..0000000 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/MpvFallbackActivity.kt +++ /dev/null @@ -1,327 +0,0 @@ -package com.ponzischeme89.memby.ui.player - -import android.content.Context -import android.content.Intent -import android.graphics.Color -import android.os.Bundle -import android.util.Log -import android.util.TypedValue -import android.view.Gravity -import android.view.KeyEvent -import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import android.widget.FrameLayout -import android.widget.TextView -import androidx.activity.ComponentActivity -import androidx.lifecycle.lifecycleScope -import com.ponzischeme89.memby.ServiceLocator -import com.ponzischeme89.memby.data.PlayableSubtitle -import com.ponzischeme89.memby.data.PlaybackSession -import `is`.xyz.mpv.BaseMPVView -import `is`.xyz.mpv.MPV -import `is`.xyz.mpv.MPVNode -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlin.math.roundToLong - -/** - * A deliberately small libmpv safety net for a stream Media3 could not decode or demux. - * Media3 remains Memby's normal Android TV player; this activity owns only the final - * compatibility hand-off and the Emby check-ins needed to keep resume state accurate. - */ -class MpvFallbackActivity : ComponentActivity(), MPV.EventObserver { - private lateinit var mpv: MPV - private lateinit var mpvView: BaseMPVView - private lateinit var controls: TextView - private var progressJob: Job? = null - private var positionMs = 0L - private var durationMs = 0L - private var loaded = false - private var stopped = false - private var controlsVisible = true - - private val itemId by lazy { intent.getStringExtra(EXTRA_ITEM_ID).orEmpty() } - private val session by lazy { - PlaybackSession( - itemId = itemId, - mediaSourceId = intent.getStringExtra(EXTRA_MEDIA_SOURCE_ID).orEmpty().ifBlank { itemId }, - playSessionId = intent.getStringExtra(EXTRA_PLAY_SESSION_ID).orEmpty(), - playMethod = intent.getStringExtra(EXTRA_PLAY_METHOD).orEmpty().ifBlank { "DirectPlay" }, - ) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val url = intent.getStringExtra(EXTRA_URL).orEmpty() - if (itemId.isBlank() || url.isBlank()) { - finish() - return - } - - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - @Suppress("DEPRECATION") - window.decorView.systemUiVisibility = - View.SYSTEM_UI_FLAG_FULLSCREEN or - View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - - val root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) } - mpvView = object : BaseMPVView(this@MpvFallbackActivity, null) { - override fun initOptions() { - // auto-safe uses hardware where mpv trusts it and falls back to software - // when the same vendor decoder that failed Media3 is not viable. - mpv.setOptionString("hwdec", "auto-safe") - mpv.setOptionString("video-sync", "audio") - } - - override fun postInitOptions() { - mpv.setPropertyBoolean("pause", true) - } - - override fun observeProperties() { - mpv.observeProperty("time-pos", MPV.mpvFormat.MPV_FORMAT_DOUBLE) - mpv.observeProperty("duration", MPV.mpvFormat.MPV_FORMAT_DOUBLE) - mpv.observeProperty("pause", MPV.mpvFormat.MPV_FORMAT_FLAG) - } - } - root.addView( - mpvView, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ), - ) - controls = TextView(this).apply { - setTextColor(Color.WHITE) - setBackgroundColor(Color.argb(185, 8, 10, 12)) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) - gravity = Gravity.CENTER - setPadding(dp(24), dp(14), dp(24), dp(14)) - text = controlText(paused = true) - } - root.addView( - controls, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL, - ).apply { bottomMargin = dp(32) }, - ) - setContentView(root) - - runCatching { - mpvView.initialize( - filesDir.resolve("mpv").path, - cacheDir.resolve("mpv").path, - ) - mpv = mpvView.mpv - mpv.addObserver(this) - mpvView.setVo("gpu") - mpvView.playFile(url) - }.onFailure { error -> - Log.e(TAG, "event=libmpv_init_failed item=$itemId", error) - finish() - } - } - - override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean = when (keyCode) { - KeyEvent.KEYCODE_DPAD_CENTER, - KeyEvent.KEYCODE_ENTER, - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, - -> { - togglePause() - true - } - KeyEvent.KEYCODE_DPAD_LEFT, - KeyEvent.KEYCODE_MEDIA_REWIND, - -> { - seekBy(-SEEK_SECONDS) - true - } - KeyEvent.KEYCODE_DPAD_RIGHT, - KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, - -> { - seekBy(SEEK_SECONDS) - true - } - KeyEvent.KEYCODE_MENU -> { - controlsVisible = !controlsVisible - controls.visibility = if (controlsVisible) View.VISIBLE else View.GONE - true - } - else -> super.onKeyDown(keyCode, event) - } - - private fun togglePause() { - val paused = mpv.getPropertyBoolean("pause") != false - mpv.setPropertyBoolean("pause", !paused) - controls.visibility = View.VISIBLE - controlsVisible = true - controls.text = controlText(paused = !paused) - reportProgress(if (!paused) "Pause" else "Unpause", isPaused = !paused) - } - - private fun seekBy(seconds: Int) { - mpv.command("seek", seconds.toString(), "relative+exact") - controls.visibility = View.VISIBLE - controlsVisible = true - controls.text = controlText(mpv.getPropertyBoolean("pause") != false) - reportProgress("TimeUpdate", mpv.getPropertyBoolean("pause") != false) - } - - private fun controlText(paused: Boolean): String { - val title = intent.getStringExtra(EXTRA_TITLE).orEmpty().ifBlank { "Compatibility playback" } - val state = if (paused) "Paused" else "Playing with libmpv" - return "$title\n$state · Left/Right seek · OK pause" - } - - private fun onFileLoaded() { - if (loaded) return - loaded = true - positionMs = intent.getLongExtra(EXTRA_POSITION_MS, 0L).coerceAtLeast(0L) - if (positionMs > 0L) mpv.setPropertyDouble("time-pos", positionMs / 1_000.0) - attachSelectedSubtitle() - mpv.setPropertyBoolean("pause", false) - controls.text = controlText(paused = false) - lifecycleScope.launch { - runCatching { ServiceLocator.repository.reportPlaybackStarted(session, positionMs) } - } - progressJob = lifecycleScope.launch { - while (isActive) { - delay(PROGRESS_INTERVAL_MS) - reportProgress("TimeUpdate", mpv.getPropertyBoolean("pause") != false) - } - } - } - - private fun attachSelectedSubtitle() { - val selectedId = intent.getStringExtra(EXTRA_SELECTED_SUBTITLE_ID).orEmpty() - if (selectedId.isBlank()) return - val subtitle = decodeSubtitles(intent.getStringExtra(EXTRA_SUBTITLES)) - .firstOrNull { it.id == selectedId && it.deliveryMethod.equals("External", true) } - ?: return - val title = subtitle.label?.takeIf(String::isNotBlank) ?: subtitle.language.orEmpty() - mpv.command("sub-add", subtitle.url, "select", title, subtitle.language.orEmpty()) - } - - private fun reportProgress(eventName: String, isPaused: Boolean) { - if (!loaded) return - lifecycleScope.launch { - runCatching { - ServiceLocator.repository.reportPlaybackProgress( - session = session, - positionMs = positionMs, - isPaused = isPaused, - eventName = eventName, - durationMs = durationMs, - ) - } - } - } - - override fun onStop() { - if (loaded && !stopped) { - stopped = true - reportProgress("Pause", isPaused = true) - PlaybackStopWorker.enqueue(this, session, positionMs) - } - if (::mpv.isInitialized) mpv.setPropertyBoolean("pause", true) - super.onStop() - } - - override fun onStart() { - super.onStart() - if (loaded && stopped) { - stopped = false - mpv.setPropertyBoolean("pause", false) - lifecycleScope.launch { - runCatching { ServiceLocator.repository.reportPlaybackStarted(session, positionMs) } - } - } - } - - override fun onDestroy() { - progressJob?.cancel() - if (::mpv.isInitialized) { - runCatching { mpv.removeObserver(this) } - runCatching { mpvView.destroy() } - } - super.onDestroy() - } - - override fun eventProperty(property: String) = Unit - override fun eventProperty(property: String, value: Long) = Unit - override fun eventProperty(property: String, value: Boolean) { - if (property == "pause") runOnUiThread { controls.text = controlText(value) } - } - override fun eventProperty(property: String, value: String) = Unit - override fun eventProperty(property: String, value: Double) { - when (property) { - "time-pos" -> positionMs = (value * 1_000.0).roundToLong().coerceAtLeast(0L) - "duration" -> durationMs = (value * 1_000.0).roundToLong().coerceAtLeast(0L) - } - } - override fun eventProperty(property: String, value: MPVNode) = Unit - override fun event(eventId: Int, data: MPVNode) { - when (eventId) { - MPV.mpvEvent.MPV_EVENT_FILE_LOADED -> runOnUiThread(::onFileLoaded) - MPV.mpvEvent.MPV_EVENT_END_FILE -> if (loaded) runOnUiThread(::finish) - } - } - - private fun dp(value: Int): Int = - TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - value.toFloat(), - resources.displayMetrics, - ).roundToLong().toInt() - - companion object { - private const val TAG = "MembyPlayback" - private const val PROGRESS_INTERVAL_MS = 10_000L - private const val SEEK_SECONDS = 10 - private const val EXTRA_ITEM_ID = "mpv_item_id" - private const val EXTRA_TITLE = "mpv_title" - private const val EXTRA_URL = "mpv_url" - private const val EXTRA_POSITION_MS = "mpv_position_ms" - private const val EXTRA_SUBTITLES = "mpv_subtitles" - private const val EXTRA_SELECTED_SUBTITLE_ID = "mpv_selected_subtitle_id" - private const val EXTRA_MEDIA_SOURCE_ID = "mpv_media_source_id" - private const val EXTRA_PLAY_SESSION_ID = "mpv_play_session_id" - private const val EXTRA_PLAY_METHOD = "mpv_play_method" - private val json = Json { ignoreUnknownKeys = true } - - fun intent( - context: Context, - itemId: String, - title: String, - url: String, - positionMs: Long, - subtitles: List, - selectedSubtitleId: String, - mediaSourceId: String, - playSessionId: String, - playMethod: String, - ): Intent = Intent(context, MpvFallbackActivity::class.java).apply { - putExtra(EXTRA_ITEM_ID, itemId) - putExtra(EXTRA_TITLE, title) - putExtra(EXTRA_URL, url) - putExtra(EXTRA_POSITION_MS, positionMs.coerceAtLeast(0L)) - if (subtitles.isNotEmpty()) putExtra(EXTRA_SUBTITLES, json.encodeToString(subtitles)) - putExtra(EXTRA_SELECTED_SUBTITLE_ID, selectedSubtitleId) - putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId) - putExtra(EXTRA_PLAY_SESSION_ID, playSessionId) - putExtra(EXTRA_PLAY_METHOD, playMethod) - } - - private fun decodeSubtitles(encoded: String?): List = - encoded?.let { - runCatching { json.decodeFromString>(it) }.getOrNull() - }.orEmpty() - } -} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt index 8047d4b..76034ff 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt @@ -88,14 +88,6 @@ internal fun automaticRetryDelayMs(attempt: Int): Long? = else -> null } -/** - * libmpv is the codec/container safety net, not a second network retry. Media3 first gets - * both automatic recovery attempts, including its lower-risk H.264 stream; only a local - * format failure that survives those attempts is handed over. - */ -internal fun shouldUseLibmpvFallback(failure: PlaybackFailure, completedAttempts: Int): Boolean = - failure.requiresTranscode && automaticRetryDelayMs(completedAttempts + 1) == null - /** One prolonged mid-programme rebuffer gets a lower-risk H.264 stream. */ internal fun shouldRecoverProlongedRebuffer( renderedFirstFrame: Boolean, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index ed69136..ca2791d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -143,7 +143,6 @@ class PlayerActivity : ComponentActivity() { private var prolongedRebufferRecoveryAttempted = false private var automaticRetryAttempt = 0 private var renderedFirstFrame = false - private var handingOffToLibmpv = false private var requestStartedAtMs = 0L private var trace = PlaybackTrace(SystemClock.elapsedRealtime(), SystemClock::elapsedRealtime) @@ -1238,41 +1237,9 @@ class PlayerActivity : ComponentActivity() { return } - if (shouldUseLibmpvFallback(failure, automaticRetryAttempt) && handOffToLibmpv()) return showPlaybackError(failure) } - /** Continue the same Emby session in libmpv without a second pre-roll or stop report. */ - private fun handOffToLibmpv(): Boolean { - val playback = player ?: return false - val id = itemId?.takeIf(String::isNotBlank) ?: return false - val url = playback.currentMediaItem?.localConfiguration?.uri?.toString() - ?.takeIf(String::isNotBlank) ?: return false - val positionMs = playback.currentPosition.coerceAtLeast(0L) - handingOffToLibmpv = true - Log.w( - PLAYBACK_LOG_TAG, - "event=libmpv_fallback item=$id positionMs=$positionMs playMethod=$playMethod", - ) - startActivity( - MpvFallbackActivity.intent( - context = this, - itemId = id, - title = playbackTitle, - url = url, - positionMs = positionMs, - subtitles = availableSubtitles, - selectedSubtitleId = serverSubtitleId, - mediaSourceId = mediaSourceId, - playSessionId = playSessionId, - playMethod = playMethod, - ), - ) - playback.pause() - finish() - return true - } - private fun retryPlayback(refreshSource: Boolean, forceTranscode: Boolean = false) { retryJob?.cancel() prolongedRebufferJob?.cancel() @@ -3774,7 +3741,7 @@ class PlayerActivity : ComponentActivity() { // this title resumes from — is the one the viewer had already skipped past. commitSeek() player?.let { - if (!handingOffToLibmpv && playbackStarted && !stopReported) { + if (playbackStarted && !stopReported) { reportProgress(it.currentPosition, isPaused = true, eventName = "Pause") stopReported = true stoppedInBackground = true @@ -3824,7 +3791,7 @@ class PlayerActivity : ComponentActivity() { "bufferingMs=$totalBufferingMs positionMs=${player?.currentPosition ?: 0L}", ) val playback = player - if (!handingOffToLibmpv && !stopReported && playbackStarted && !itemId.isNullOrBlank()) { + if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) { stopReported = true PlaybackStopWorker.enqueue( this, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index bcae20b..b5ede69 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -164,6 +164,7 @@ fun SearchScreen( returnFocusRequester: FocusRequester, initialQuery: String? = null, onInitialQueryConsumed: () -> Unit = {}, + onSearchStarted: () -> Unit = {}, onItemFocused: (BaseItem) -> Unit, onItemSelected: (BaseItem) -> Unit, onContentFocused: () -> Unit, @@ -194,6 +195,15 @@ fun SearchScreen( var lastKeyIndex by remember { mutableIntStateOf(0) } var focusInResults by remember { mutableStateOf(false) } var restoreGenreChipFocus by remember { mutableStateOf(false) } + var searchTracked by remember { mutableStateOf(false) } + LaunchedEffect(state.query) { + if (state.query.isNotBlank() && !searchTracked) { + searchTracked = true + onSearchStarted() + } else if (state.query.isBlank()) { + searchTracked = false + } + } val hasResultsTarget = when { state.errorMessage != null && state.results.isEmpty() -> true state.isDiscovery -> discoveryItems.isNotEmpty() || diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 2bc090e..4c87836 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -319,6 +319,7 @@ fun SettingsSheet( modifier: Modifier = Modifier, overlay: Boolean = false, navigationFocusRequester: FocusRequester? = null, + onAnalyticsEvent: (feature: String, action: String) -> Unit = { _, _ -> }, ) { val context = LocalContext.current val store = ServiceLocator.settings @@ -496,30 +497,37 @@ fun SettingsSheet( val actions = SettingsPanelActions( onClose = onClose, onShowLogoChanged = { + onAnalyticsEvent("title_logo", "toggle") showLogo = it scope.launch { store.setShowTitleLogo(it) } }, onAutoPlayNextChanged = { + onAnalyticsEvent("auto_play_next", "toggle") autoPlayNext = it scope.launch { store.setAutoPlayNextEpisode(it) } }, onShowTenMinuteReminderChanged = { + onAnalyticsEvent("playback_reminder", "toggle") showTenMinuteReminder = it scope.launch { store.setShowTenMinuteReminder(it) } }, onSeekIntervalChanged = { + onAnalyticsEvent("seek_interval", "change") seekInterval = it scope.launch { store.setSeekIntervalSeconds(it) } }, onSkipIntroModeChanged = { + onAnalyticsEvent("skip_intro", "change") skipIntroMode = it scope.launch { store.setSkipIntroMode(it) } }, onSpeedUpCreditsChanged = { + onAnalyticsEvent("speed_up_credits", "toggle") speedUpCredits = it scope.launch { store.setSpeedUpCredits(it) } }, onAudioPassthroughModeChanged = { mode -> + onAnalyticsEvent("audio_passthrough", "change") audioPassthroughMode = mode // Moonfin seeds a first manual visit from the live probe. That makes Manual // start as an editable copy of Auto instead of unexpectedly switching every @@ -530,6 +538,7 @@ fun SettingsSheet( scope.launch { store.setAudioPassthrough(mode, audioPassthroughCodecs) } }, onAudioPassthroughCodecChanged = { codec, enabled -> + onAnalyticsEvent("audio_passthrough", "toggle") audioPassthroughCodecs = if (enabled) { audioPassthroughCodecs + codec } else { @@ -540,18 +549,22 @@ fun SettingsSheet( } }, onRingColorChanged = { + onAnalyticsEvent("focus_colour", "change") ringColor = it scope.launch { store.setRingColor(it) } }, onHomeSectionChanged = { key, enabled -> + onAnalyticsEvent("home_sections", "toggle") homeSections = if (enabled) homeSections + key else homeSections - key scope.launch { store.setHomeSections(homeSections.toList()) } }, onCardDensityChanged = { + onAnalyticsEvent("card_density", "change") cardDensity = it scope.launch { store.setHomeCardDensity(it) } }, onArtworkStyleChanged = { + onAnalyticsEvent("artwork_style", "change") artworkStyle = it scope.launch { // Back closes this composable and cancels its scope. Once a selection has @@ -561,6 +574,7 @@ fun SettingsSheet( } }, onRestoreHiddenRows = { + onAnalyticsEvent("hidden_rows", "change") scope.launch { store.setHomeRowPreferences( settings.homeRowOrder.lineSequence().filter { it.isNotBlank() }.toList(), @@ -570,22 +584,27 @@ fun SettingsSheet( } }, onShowCardMetadataChanged = { + onAnalyticsEvent("card_metadata", "toggle") showCardMetadata = it scope.launch { store.setShowHomeCardMetadata(it) } }, onShowRatingsStripChanged = { + onAnalyticsEvent("ratings_strip", "toggle") showRatingsStrip = it scope.launch { store.setShowRatingsStrip(it) } }, onHideWatchedMoviesChanged = { + onAnalyticsEvent("hide_watched_movies", "toggle") hideWatchedMovies = it scope.launch { store.setHideWatchedMovies(it) } }, onConfirmExitMembyChanged = { + onAnalyticsEvent("confirm_exit", "toggle") confirmExitMemby = it scope.launch { store.setConfirmExitMemby(it) } }, onThemeChanged = { chosen -> + onAnalyticsEvent("theme", "change") // No local echo: what is on screen is the palette the gateway resolves, and it // arrives through ThemeSync a moment later. Painting optimistically here would // show a viewer a scheme that a season, or an allowlist they do not know about, @@ -593,10 +612,14 @@ fun SettingsSheet( scope.launch { store.setThemeId(chosen) } }, onWelcomeQuoteStyleChanged = { + onAnalyticsEvent("welcome_quote", "change") welcomeQuoteStyle = it scope.launch { store.setWelcomeQuoteStyle(it) } }, - onPageSelected = { selectedPage = it }, + onPageSelected = { + onAnalyticsEvent("settings_page_${it.name.lowercase()}", "open") + selectedPage = it + }, onRefreshDevices = { deviceJob?.cancel() deviceJob = scope.launch { refreshDevices() } diff --git a/app/src/test/java/com/ponzischeme89/memby/data/analytics/JourneyAnalyticsTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/analytics/JourneyAnalyticsTest.kt new file mode 100644 index 0000000..5c63e11 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/analytics/JourneyAnalyticsTest.kt @@ -0,0 +1,38 @@ +package com.ponzischeme89.memby.data.analytics + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class JourneyAnalyticsTest { + @Test + fun `events stay ordered and contain no arbitrary characters`() { + val analytics = JourneyAnalytics(userId = "user-1", now = { 1_700_000_000_000L }, journeyId = "journey-1") + analytics.track( + category = "content", action = "open", screen = "home screen", + feature = "latest_movies", itemId = "item/unsafe", itemType = "Movie", + ) + val events = analytics.drain() + + assertEquals(listOf(0, 1), events.map { it.sequence }) + assertEquals("user-1", events.last().userId) + assertEquals("homescreen", events.last().screen) + assertEquals("itemunsafe", events.last().itemId) + } + + @Test + fun `ending records one terminal event and rejects later actions`() { + val analytics = JourneyAnalytics(userId = "user-1", journeyId = "journey-1") + analytics.drain() + analytics.end("details") + analytics.end("player") + analytics.track("navigation", "open", feature = "search") + val events = analytics.drain() + + assertEquals(1, events.size) + assertEquals("journey_end", events.single().action) + assertTrue(events.single().screen == "details") + assertFalse(events.any { it.feature == "search" }) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt index e6a54cd..139183d 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt @@ -9,26 +9,6 @@ import org.junit.Test class PlaybackRecoveryTest { - @Test - fun `libmpv takes codec failures only after Media3 recovery is exhausted`() { - val codecFailure = describePlaybackFailure( - PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, - ) - - assertFalse(shouldUseLibmpvFallback(codecFailure, completedAttempts = 0)) - assertFalse(shouldUseLibmpvFallback(codecFailure, completedAttempts = 1)) - assertTrue(shouldUseLibmpvFallback(codecFailure, completedAttempts = 2)) - } - - @Test - fun `libmpv does not replace Media3 network recovery`() { - val networkFailure = describePlaybackFailure( - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, - ) - - assertFalse(shouldUseLibmpvFallback(networkFailure, completedAttempts = 2)) - } - @Test fun networkFailuresAreSafeToRetry() { val failure = describePlaybackFailure( diff --git a/server/README.md b/server/README.md index b2c1141..3ce6f3f 100644 --- a/server/README.md +++ b/server/README.md @@ -532,6 +532,14 @@ landing page serves), release notes, and a **Require this update** toggle. `minimumVersion` can also be set directly for a staged rollout where the forced floor is older than the newest build. +Builds below 0.2.44 are permanently retired once the enabled policy points at an +actionable 0.2.44-or-newer release. On their next authenticated request the gateway +deletes the session and returns 401, which makes the TV remove the rejected local profile; +the public update check continues to return the mandatory update screen. The gateway also +refuses a new login from a retired build, so signing in again cannot bypass the update. +This floor remains dormant when the policy has no download URL or its latest release is +older than 0.2.44. + Two deliberate safeguards, both tested in `internal/appupdate`: - A client that cannot report a version is **never** forced. It would otherwise be stuck diff --git a/server/cmd/memby-server/main.go b/server/cmd/memby-server/main.go index 0a8851f..090503a 100644 --- a/server/cmd/memby-server/main.go +++ b/server/cmd/memby-server/main.go @@ -309,9 +309,7 @@ func openStore(ctx context.Context, databaseURL string, log *slog.Logger) (*stor return nil, lastErr } -// pruneAnalytics keeps raw row events inside their retention window. The admin page -// aggregates at read time, so nothing survives the prune — deliberately, since this is -// tuning telemetry rather than a permanent record of what anyone watched. +// pruneAnalytics keeps raw engagement and journey events inside their retention window. func pruneAnalytics(ctx context.Context, st *store.Store, retention time.Duration, log *slog.Logger) { if retention <= 0 { return diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 8622acd..fdaa332 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -720,7 +720,29 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "could not read analytics") return } - writeJSON(w, http.StatusOK, map[string]any{"days": days, "rows": stats}) + users, err := s.store.AnalyticsUsers(r.Context(), since) + if err != nil { + s.loggerFor(r.Context()).Error("user analytics failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not read analytics") + return + } + payload := map[string]any{"days": days, "retentionDays": int(s.cfg.AnalyticsRetention / (24 * time.Hour)), "rows": stats, "users": users} + userID := strings.TrimSpace(r.URL.Query().Get("userId")) + if userID != "" { + features, featureErr := s.store.UserFeatureStats(r.Context(), userID, since) + paths, pathErr := s.store.UserPaths(r.Context(), userID, since) + events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000) + if featureErr != nil || pathErr != nil || eventErr != nil { + s.loggerFor(r.Context()).Error("user journey read failed", "user_id", userID) + writeError(w, http.StatusInternalServerError, "could not read user journey") + return + } + payload["userId"] = userID + payload["features"] = features + payload["paths"] = paths + payload["events"] = events + } + writeJSON(w, http.StatusOK, payload) } // syncerHandle is the slice of the syncer the API needs, so api does not depend on the diff --git a/server/internal/api/admin/pages/engagement.html b/server/internal/api/admin/pages/engagement.html index 8a6d839..532e397 100644 --- a/server/internal/api/admin/pages/engagement.html +++ b/server/internal/api/admin/pages/engagement.html @@ -8,8 +8,9 @@
@@ -24,3 +25,53 @@
+ +
+
+
+

User journeys

+

Choose an Emby profile to review feature use, common paths and + significant actions in time order. Search text, content titles and setting values + are not stored in journey analytics.

+
+ +
+
+
+ + + + + + diff --git a/server/internal/api/admin/pages/engagement.js b/server/internal/api/admin/pages/engagement.js index ab8f65b..8b05684 100644 --- a/server/internal/api/admin/pages/engagement.js +++ b/server/internal/api/admin/pages/engagement.js @@ -1,11 +1,81 @@ const { fmt, ui, $ } = Admin; +const featureCatalogue = [ + 'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches', + 'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue', + 'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings', +]; + +const label = (value) => { + const text = String(value || '—').replaceAll('_', ' '); + if (text === 'favorites') return 'Favourites'; + if (text === 'abandoned') return 'Abandoned / interrupted'; + return text; +}; + +function renderUser(payload) { + const selected = $('engagement-user').value; + const users = payload.users || []; + const current = users.find((user) => user.userId === selected); + $('engagement-user-tiles').innerHTML = current ? ui.tiles([ + ['events', fmt.number(current.events), { icon: 'pulse', tone: 'info' }], + ['journeys', fmt.number(current.journeys), { icon: 'list', tone: 'data' }], + ['last active', fmt.when(current.lastActiveAt), { icon: 'clock', tone: 'note', small: true }], + ['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }], + ]) : ''; + + ['feature', 'path', 'journey'].forEach((name) => { + $('engagement-' + name + '-card').hidden = !current; + }); + if (!current) return; + + const used = new Map((payload.features || []).map((feature) => [feature.feature, feature])); + const features = [...new Set([...featureCatalogue, ...used.keys()])]; + $('engagement-features').innerHTML = features.map((name) => { + const stat = used.get(name); + const uses = stat?.uses || 0; + const status = uses === 0 ? ui.tag('not used', 'warn') : uses < 3 ? ui.tag('rare', 'note') : ui.tag('used', 'ok'); + return '' + fmt.escape(label(name)) + '' + fmt.number(uses) + + '' + (stat ? fmt.when(stat.lastUsedAt) : '—') + '' + status + ''; + }).join(''); + + const paths = payload.paths || []; + $('engagement-paths').innerHTML = paths.length ? paths.map((path) => + '' + fmt.escape(label(path.from)) + '' + fmt.escape(label(path.to)) + + '' + fmt.number(path.count) + '').join('') + : ui.emptyRow(3, 'No repeated paths in this window.'); + + const grouped = new Map(); + (payload.events || []).forEach((event) => { + if (!grouped.has(event.journeyId)) grouped.set(event.journeyId, []); + grouped.get(event.journeyId).push(event); + }); + let journeyNumber = grouped.size; + const rows = []; + grouped.forEach((events) => { + events.sort((a, b) => a.sequence - b.sequence); + const number = journeyNumber--; + events.forEach((event) => { + const path = event.source && event.target ? label(event.source) + ' → ' + label(event.target) + : label(event.target || event.screen); + const content = event.itemId ? label(event.itemType) + ' · ' + event.itemId : '—'; + rows.push('' + fmt.when(event.occurredAt) + '' + + '' + number + '' + fmt.escape(label(event.action)) + '' + + '' + fmt.escape(path) + '' + fmt.escape(label(event.feature)) + '' + + '' + fmt.escape(content) + '' + fmt.escape(label(event.outcome)) + ''); + }); + }); + $('engagement-events').innerHTML = rows.length ? rows.join('') : ui.emptyRow(7, 'No journey events in this window.'); +} + Admin.onRefresh(async () => { - const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value); + const selected = $('engagement-user').value; + const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value + + (selected ? '&userId=' + encodeURIComponent(selected) : '')); const rows = payload.rows || []; $('engagement-rows').innerHTML = rows.length ? rows.map((row) => - '' + fmt.escape(row.rowId) + '' + - '' + fmt.escape(row.rowKind || '—') + '' + + '' + fmt.escape(label(row.rowId)) + '' + + '' + fmt.escape(label(row.rowKind)) + '' + '' + fmt.duration(row.dwellMs) + '' + '' + fmt.number(row.impressions) + '' + '' + fmt.number(row.focuses) + '' + @@ -13,6 +83,14 @@ Admin.onRefresh(async () => { '' + Math.round((row.selectRate || 0) * 100) + '%' + '' + fmt.number(row.viewers) + '').join('') : ui.emptyRow(8, 'No events in this window.'); + + const users = payload.users || []; + const existing = $('engagement-user').value; + $('engagement-user').innerHTML = '' + users.map((user) => + '').join(''); + if (users.some((user) => user.userId === existing)) $('engagement-user').value = existing; + renderUser(payload); }); Admin.ready(() => $('engagement-days').addEventListener('change', Admin.refresh)); +Admin.ready(() => $('engagement-user').addEventListener('change', Admin.refresh)); diff --git a/server/internal/api/analytics.go b/server/internal/api/analytics.go index 497387a..12b1fc1 100644 --- a/server/internal/api/analytics.go +++ b/server/internal/api/analytics.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "strings" "time" "github.com/ponzischeme89/memby/server/internal/store" @@ -30,6 +31,108 @@ type analyticsRequest struct { Events []rowEventPayload `json:"events"` } +type journeyEventPayload struct { + UserID string `json:"userId"` + JourneyID string `json:"journeyId"` + Sequence int `json:"sequence"` + Category string `json:"category"` + Action string `json:"action"` + Screen string `json:"screen"` + Feature string `json:"feature"` + Source string `json:"source"` + Target string `json:"target"` + ItemID string `json:"itemId"` + ItemType string `json:"itemType"` + Outcome string `json:"outcome"` + OccurredAt string `json:"occurredAt"` +} + +type journeyAnalyticsRequest struct { + Events []journeyEventPayload `json:"events"` +} + +var journeyCategories = allowedAnalyticsValues("session", "navigation", "content", "search", "playback", "settings", "recommendations", "library", "profile", "notifications") +var journeyActions = allowedAnalyticsValues( + "journey_start", "journey_end", "screen_view", "open", "close", "select", + "submit", "request", "start", "stop", "complete", "abandon", "change", + "toggle", "follow", "unfollow", "favourite", "unfavourite", "mark_played", + "mark_unplayed", "retry", "dismiss", "switch", +) +var journeyOutcomes = allowedAnalyticsValues("", "success", "failure", "cancelled", "completed", "abandoned") + +func allowedAnalyticsValues(values ...string) map[string]bool { + out := make(map[string]bool, len(values)) + for _, value := range values { + out[value] = true + } + return out +} + +// handleJourneyAnalytics accepts privacy-bounded journey steps. The payload's user id is +// only a profile-switch guard: authority always comes from the bearer session. +func (s *Server) handleJourneyAnalytics(w http.ResponseWriter, r *http.Request, sess store.Session) { + var req journeyAnalyticsRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + if len(req.Events) > maxAnalyticsBatch { + req.Events = req.Events[:maxAnalyticsBatch] + } + now := time.Now().UTC() + events := make([]store.JourneyEvent, 0, len(req.Events)) + for _, payload := range req.Events { + if event, ok := toJourneyEvent(payload, sess.EmbyUserID, now); ok { + events = append(events, event) + } + } + if err := s.store.InsertJourneyEvents(r.Context(), events); err != nil { + s.loggerFor(r.Context()).Warn("journey analytics write failed", "error", err) + } + w.WriteHeader(http.StatusNoContent) +} + +func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (store.JourneyEvent, bool) { + if payload.UserID != userID || !safeAnalyticsValue(payload.JourneyID, 80) || + payload.Sequence < 0 || !journeyCategories[payload.Category] || !journeyActions[payload.Action] || + !journeyOutcomes[payload.Outcome] { + return store.JourneyEvent{}, false + } + fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target, payload.ItemID, payload.ItemType} + for _, field := range fields { + if !safeAnalyticsValue(field, 100) { + return store.JourneyEvent{}, false + } + } + occurredAt := analyticsOccurredAt(payload.OccurredAt, now) + return store.JourneyEvent{OccurredAt: occurredAt, UserID: userID, JourneyID: payload.JourneyID, + Sequence: payload.Sequence, Category: payload.Category, Action: payload.Action, + Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source, + Target: payload.Target, ItemID: payload.ItemID, ItemType: payload.ItemType, + Outcome: payload.Outcome}, true +} + +func safeAnalyticsValue(value string, max int) bool { + if len(value) > max { + return false + } + for _, char := range value { + if !(char == '-' || char == '_' || char == '.' || char == ':' || + char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') { + return false + } + } + return true +} + +func analyticsOccurredAt(value string, now time.Time) time.Time { + if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil && + parsed.After(now.Add(-24*time.Hour)) && parsed.Before(now.Add(time.Hour)) { + return parsed.UTC() + } + return now +} + // handleRowAnalytics accepts a batch of row engagement events from a TV. // // Fire-and-forget by design: the client does not retry, and a rejected event is never @@ -81,16 +184,7 @@ func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.Ro return store.RowEvent{}, false } - occurredAt := now - if payload.OccurredAt != "" { - if parsed, err := time.Parse(time.RFC3339, payload.OccurredAt); err == nil { - // Trust the device's clock only within a sane window; TVs are notorious for - // waking up in 1970. - if parsed.After(now.Add(-24*time.Hour)) && parsed.Before(now.Add(time.Hour)) { - occurredAt = parsed.UTC() - } - } - } + occurredAt := analyticsOccurredAt(payload.OccurredAt, now) dwell := payload.DwellMs if dwell < 0 { diff --git a/server/internal/api/analytics_test.go b/server/internal/api/analytics_test.go new file mode 100644 index 0000000..a3575cc --- /dev/null +++ b/server/internal/api/analytics_test.go @@ -0,0 +1,38 @@ +package api + +import ( + "testing" + "time" +) + +func TestJourneyEventUsesAuthenticatedUser(t *testing.T) { + now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC) + payload := journeyEventPayload{UserID: "user-1", JourneyID: "journey-1", Sequence: 3, + Category: "navigation", Action: "open", Screen: "home", Feature: "search", Target: "search"} + + event, ok := toJourneyEvent(payload, "user-1", now) + if !ok { + t.Fatal("valid event was rejected") + } + if event.UserID != "user-1" || event.Sequence != 3 { + t.Fatalf("unexpected event: %+v", event) + } + if _, ok := toJourneyEvent(payload, "user-2", now); ok { + t.Fatal("an event buffered under another profile was accepted") + } +} + +func TestJourneyEventRejectsFreeTextAndUnknownVocabulary(t *testing.T) { + now := time.Now().UTC() + base := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content", Action: "open"} + withTitle := base + withTitle.Feature = "A Film Title" + if _, ok := toJourneyEvent(withTitle, "u1", now); ok { + t.Fatal("free text feature was accepted") + } + unknown := base + unknown.Action = "typed_query" + if _, ok := toJourneyEvent(unknown, "u1", now); ok { + t.Fatal("unknown action was accepted") + } +} diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 5f1cc3b..2ca2366 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -126,7 +126,7 @@ func (s *Server) Routes() http.Handler { // once, without the gate ever touching health checks or the admin page. v1 := http.NewServeMux() - v1.HandleFunc("POST /v1/auth/login", s.handleLogin) + v1.HandleFunc("POST /v1/auth/login", s.requireSupportedClient(s.handleLogin)) v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout)) v1.Handle("GET /v1/auth/session", s.authed(s.handleSession)) v1.Handle("GET /v1/auth/devices", s.authed(s.handleDevices)) @@ -196,6 +196,7 @@ func (s *Server) Routes() http.Handler { v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport)) v1.Handle("POST /v1/analytics/rows", s.authed(s.handleRowAnalytics)) + v1.Handle("POST /v1/analytics/events", s.authed(s.handleJourneyAnalytics)) v1.Handle("GET /v1/images/{itemId}/{imageType}", s.authed(s.handleImage)) @@ -204,8 +205,8 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /readyz", s.handleReady) // Update policy is app-scoped, not user-scoped. Keep it outside authentication and // maintenance so a fresh install, a signed-out TV, and a retired build can all learn - // whether the server requires an update without touching a viewer session. - mux.HandleFunc("GET /v1/update", s.handleUpdate) + // whether the server requires an update. A valid session enriches only its log context. + mux.Handle("GET /v1/update", s.identifyOptionalSession(http.HandlerFunc(s.handleUpdate))) // Exact route outside the maintenance gate: signed-in clients poll this lightweight // status even while every normal /v1 operation is deliberately unavailable. mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus)) @@ -230,6 +231,21 @@ func (s *Server) Routes() http.Handler { type authedFunc func(http.ResponseWriter, *http.Request, store.Session) +// identifyOptionalSession gives public routes the viewer and television attached to a +// valid bearer token without turning authentication into a condition of access. The +// update check must remain reachable before sign-in, but an offer made to a signed-in +// client should still say whose session is affected in the logs. +func (s *Server) identifyOptionalSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if token := bearerToken(r); token != "" { + if sess, err := s.sessionFor(r.Context(), token); err == nil { + identify(r.Context(), sess) + } + } + next.ServeHTTP(w, r) + }) +} + // authed resolves the bearer token to a session before running h. // // Images are also accepted with a `t=` query parameter: Coil builds plain URLs from the @@ -253,6 +269,26 @@ func (s *Server) authed(h authedFunc) http.Handler { } sess = s.captureClientIdentity(r, sess) identify(r.Context(), sess) + decision := s.updateDecision(r) + if mustRetireForUpdate(decision, clientVersion(r)) { + // Mirror an ordinary sign-out closely enough that this token cannot be restored + // from either database or Redis. The 401 is intentional: every supported client + // treats it as authoritative and removes the rejected local profile. + if err := s.store.DeleteSession(r.Context(), sess.TokenHash); err != nil { + s.loggerFor(r.Context()).Error("required-update session delete failed", "error", err) + } + _ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(sess.TokenHash))) + _ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID) + s.loggerFor(r.Context()).Info("signed out for required update", + "device_id", sess.DeviceID, + "from", clientLogValue(clientVersion(r)), + "minimum", forcedUpdateFloor, + "to", decision.Version, + ) + w.Header().Set("X-Memby-Update-Required", decision.Version) + writeError(w, http.StatusUnauthorized, "Memby must be updated before signing in again") + return + } h(w, r, sess) }) } diff --git a/server/internal/api/update.go b/server/internal/api/update.go index 2590559..73b01e0 100644 --- a/server/internal/api/update.go +++ b/server/internal/api/update.go @@ -17,6 +17,12 @@ import ( // speaks, next to the build's own version. const ProtocolVersion = 1 +// forcedUpdateFloor retires builds whose update behaviour is no longer reliable enough +// to leave optional. The floor only takes effect once an enabled policy points at this +// version (or a newer one) and carries a download URL, so deploying the gateway before +// publishing the APK cannot lock televisions out. +const forcedUpdateFloor = "0.2.44" + // updatePolicyCache keeps the policy in memory. It is read on every home request, and a // database round trip per home load to answer "nothing to say" would be wasteful. type updatePolicyCache struct { @@ -90,13 +96,54 @@ func compatibilityFor(r *http.Request) (bool, string) { return true, "" } +// effectiveUpdatePolicy applies the server-owned emergency floor without weakening a +// higher minimum the operator has already selected. +func effectiveUpdatePolicy(policy appupdate.Policy) appupdate.Policy { + if !policy.Enabled || strings.TrimSpace(policy.DownloadURL) == "" || + appupdate.CompareVersions(policy.LatestVersion, forcedUpdateFloor) < 0 { + return policy + } + if strings.TrimSpace(policy.MinimumVersion) == "" || + appupdate.CompareVersions(policy.MinimumVersion, forcedUpdateFloor) < 0 { + policy.MinimumVersion = forcedUpdateFloor + } + return policy +} + +func (s *Server) updateDecision(r *http.Request) appupdate.Decision { + return appupdate.Decide(effectiveUpdatePolicy(s.updatePolicy.get()), clientVersion(r)) +} + +// mustRetireForUpdate is narrower than "mandatory": an operator may temporarily force a +// newer release without wanting every otherwise supported session destroyed. Only builds +// below the permanent compatibility floor are signed out. +func mustRetireForUpdate(decision appupdate.Decision, version string) bool { + return decision.Status == appupdate.StatusMandatory && decision.DownloadURL != "" && + appupdate.CompareVersions(version, forcedUpdateFloor) < 0 +} + +// requireSupportedClient prevents a retired build from signing straight back in after +// the authenticated gate has removed its old session. Its public update check remains +// available and will keep returning the actionable mandatory verdict. +func (s *Server) requireSupportedClient(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + decision := s.updateDecision(r) + if mustRetireForUpdate(decision, clientVersion(r)) { + w.Header().Set("X-Memby-Update-Required", decision.Version) + writeJSON(w, http.StatusUpgradeRequired, decision) + return + } + next(w, r) + } +} + // handleUpdate answers the client's version check. // // Its own public endpoint rather than a field on /v1/home: update policy belongs to the -// app build, not a viewer or login. The only client input is its build-version header and -// the answer comes from memory, so checking it never reads or mutates a user session. +// app build, not a viewer or login. The verdict comes from memory; when a bearer token is +// present the route resolves it only to attribute an offered update to the affected viewer. func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) { - decision := appupdate.Decide(s.updatePolicy.get(), clientVersion(r)) + decision := s.updateDecision(r) // Only a verdict that asks a television to do something is worth a line. Every TV // checks on every launch, and "nothing to say" logged each time would bury the // launch where an update was actually offered — or forced. diff --git a/server/internal/api/update_test.go b/server/internal/api/update_test.go index 9f8f731..6e0429f 100644 --- a/server/internal/api/update_test.go +++ b/server/internal/api/update_test.go @@ -2,12 +2,15 @@ package api import ( "encoding/json" + "io" + "log/slog" "net/http" "net/http/httptest" "testing" "github.com/ponzischeme89/memby/server/internal/appupdate" "github.com/ponzischeme89/memby/server/internal/config" + serverlogging "github.com/ponzischeme89/memby/server/internal/logging" "github.com/ponzischeme89/memby/server/internal/store" ) @@ -38,3 +41,105 @@ func TestUpdateStatusIsPublicAndAvailableDuringMaintenance(t *testing.T) { t.Fatalf("status = %q, want mandatory", decision.Status) } } + +func TestUpdateOfferLogNamesTheAffectedViewer(t *testing.T) { + logger, events := serverlogging.NewBuffered(io.Discard, slog.LevelInfo, 10, serverlogging.FormatConsole) + server := New(config.Config{}, Deps{Log: logger, Events: events}) + server.updatePolicy.set(appupdate.Policy{ + Enabled: true, + LatestVersion: "0.2.44", + DownloadURL: "/updates/memby-0.2.44.apk", + }) + + req := httptest.NewRequest(http.MethodGet, "/v1/update", nil) + req.Header.Set("X-Memby-Version", "0.2.38") + req, _ = withRequestIdentity(req) + identify(req.Context(), store.Session{Username: "matt", DeviceName: "Living room"}) + server.handleUpdate(httptest.NewRecorder(), req) + + page := events.Events(0, 10) + if len(page.Events) != 1 { + t.Fatalf("events = %d, want 1", len(page.Events)) + } + event := page.Events[0] + if event.Message != "update offered" || event.Attributes["user"] != "matt" { + t.Fatalf("update event was not attributed to the viewer: %+v", event) + } +} + +func TestEmergencyFloorForcesClientsBelow0244(t *testing.T) { + server := testServer(config.Config{}) + server.updatePolicy.set(appupdate.Policy{ + Enabled: true, + LatestVersion: "0.2.44", + DownloadURL: "/updates/memby-0.2.44.apk?token=signed", + }) + + for version, want := range map[string]string{ + "0.2.43": appupdate.StatusMandatory, + "0.2.44": appupdate.StatusNone, + "0.2.45": appupdate.StatusNone, + } { + req := httptest.NewRequest(http.MethodGet, "/v1/update", nil) + req.Header.Set("X-Memby-Version", version) + rec := httptest.NewRecorder() + server.handleUpdate(rec, req) + + var decision appupdate.Decision + if err := json.Unmarshal(rec.Body.Bytes(), &decision); err != nil { + t.Fatalf("%s: decode decision: %v", version, err) + } + if decision.Status != want { + t.Errorf("%s: status = %q, want %q", version, decision.Status, want) + } + } +} + +func TestEmergencyFloorWaitsForAnActionableRelease(t *testing.T) { + for name, policy := range map[string]appupdate.Policy{ + "disabled": { + LatestVersion: "0.2.44", DownloadURL: "/updates/memby-0.2.44.apk", + }, + "missing download": { + Enabled: true, LatestVersion: "0.2.44", + }, + "release too old": { + Enabled: true, LatestVersion: "0.2.43", DownloadURL: "/updates/memby-0.2.43.apk", + }, + } { + t.Run(name, func(t *testing.T) { + decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.43") + if mustRetireForUpdate(decision, "0.2.43") { + t.Fatal("client would be retired without an actionable 0.2.44-or-newer release") + } + }) + } +} + +func TestRetiredClientCannotSignBackIn(t *testing.T) { + server := testServer(config.Config{}) + server.updatePolicy.set(appupdate.Policy{ + Enabled: true, + LatestVersion: "0.2.44", + DownloadURL: "/updates/memby-0.2.44.apk?token=signed", + }) + reached := false + handler := server.requireSupportedClient(func(http.ResponseWriter, *http.Request) { + reached = true + }) + req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil) + req.Header.Set("X-Memby-Version", "0.2.43") + rec := httptest.NewRecorder() + + handler(rec, req) + + if reached { + t.Fatal("retired client reached the login handler") + } + if rec.Code != http.StatusUpgradeRequired { + t.Fatalf("status = %d, want 426", rec.Code) + } + if rec.Header().Get("X-Memby-Update-Required") != "0.2.44" { + t.Fatalf("required update header = %q", rec.Header().Get("X-Memby-Update-Required")) + } +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 81199a7..8b5f399 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -75,7 +75,8 @@ type Config struct { SyncUserID string SyncAPIKey string - // AnalyticsRetention is how long raw row events are kept before being pruned. + // AnalyticsRetention is how long raw row and journey events are kept before pruning. + // Load enforces a 30-day floor so the per-user history promise cannot be configured away. AnalyticsRetention time.Duration // Sonarr is optional. When configured, its local calendar supplies the informational @@ -192,6 +193,9 @@ func Load() (Config, error) { ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour), ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4), } + if c.AnalyticsRetention < 30*24*time.Hour { + c.AnalyticsRetention = 30 * 24 * time.Hour + } if c.EmbyURL == "" { return c, fmt.Errorf("MEMBY_EMBY_URL is required") diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go index 8749181..ab26b84 100644 --- a/server/internal/config/config_test.go +++ b/server/internal/config/config_test.go @@ -63,3 +63,17 @@ func TestRecommendationWeightsMustBeJSON(t *testing.T) { t.Fatal("expected invalid recommendation weights to fail") } } + +func TestAnalyticsRetentionCannotDropBelowThirtyDays(t *testing.T) { + t.Setenv("MEMBY_EMBY_URL", "http://emby") + t.Setenv("MEMBY_DATABASE_URL", "postgres://memby") + t.Setenv("MEMBY_ANALYTICS_RETENTION", "168h") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.AnalyticsRetention != 30*24*time.Hour { + t.Fatalf("analytics retention = %v, want 30 days", cfg.AnalyticsRetention) + } +} diff --git a/server/internal/store/analytics.go b/server/internal/store/analytics.go index 8fd7701..1e52a64 100644 --- a/server/internal/store/analytics.go +++ b/server/internal/store/analytics.go @@ -58,6 +58,45 @@ type RowEvent struct { DwellMs int } +// JourneyEvent is one significant step through the app. All descriptive fields are +// controlled vocabulary; ItemID is the only content identity retained. +type JourneyEvent struct { + ID int64 `json:"id"` + OccurredAt time.Time `json:"occurredAt"` + UserID string `json:"userId"` + JourneyID string `json:"journeyId"` + Sequence int `json:"sequence"` + Category string `json:"category"` + Action string `json:"action"` + Screen string `json:"screen"` + Feature string `json:"feature"` + Source string `json:"source"` + Target string `json:"target"` + ItemID string `json:"itemId,omitempty"` + ItemType string `json:"itemType,omitempty"` + Outcome string `json:"outcome,omitempty"` +} + +type AnalyticsUser struct { + UserID string `json:"userId"` + Username string `json:"username"` + Events int64 `json:"events"` + Journeys int64 `json:"journeys"` + LastActiveAt time.Time `json:"lastActiveAt"` +} + +type FeatureStat struct { + Feature string `json:"feature"` + Uses int64 `json:"uses"` + LastUsedAt time.Time `json:"lastUsedAt"` +} + +type PathStat struct { + From string `json:"from"` + To string `json:"to"` + Count int64 `json:"count"` +} + // Event kinds. Impressions say a row was drawn; focus says the remote actually landed // on it and for how long; select says something was opened from it. const ( @@ -141,6 +180,137 @@ func (s *Store) InsertRowEvents(ctx context.Context, events []RowEvent) error { return nil } +func (s *Store) InsertJourneyEvents(ctx context.Context, events []JourneyEvent) error { + if len(events) == 0 { + return nil + } + batch := &pgx.Batch{} + for _, event := range events { + batch.Queue(` + INSERT INTO journey_events + (occurred_at, emby_user_id, journey_id, sequence, category, action, screen, + feature, source, target, item_id, item_type, outcome) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + ON CONFLICT (emby_user_id, journey_id, sequence) DO NOTHING`, + event.OccurredAt, event.UserID, event.JourneyID, event.Sequence, + event.Category, event.Action, event.Screen, event.Feature, event.Source, + event.Target, event.ItemID, event.ItemType, event.Outcome) + } + results := s.pool.SendBatch(ctx, batch) + defer results.Close() + for range events { + if _, err := results.Exec(); err != nil { + return fmt.Errorf("store: insert journey events: %w", err) + } + } + return nil +} + +func (s *Store) AnalyticsUsers(ctx context.Context, since time.Time) ([]AnalyticsUser, error) { + rows, err := s.pool.Query(ctx, ` + SELECT je.emby_user_id, + coalesce((array_agg(s.username ORDER BY s.last_seen_at DESC) + FILTER (WHERE s.username IS NOT NULL))[1], ''), + count(DISTINCT je.id), count(DISTINCT je.journey_id), max(je.occurred_at) + FROM journey_events je + LEFT JOIN sessions s ON s.emby_user_id = je.emby_user_id + WHERE je.occurred_at >= $1 + GROUP BY je.emby_user_id ORDER BY max(je.occurred_at) DESC`, since) + if err != nil { + return nil, fmt.Errorf("store: analytics users: %w", err) + } + defer rows.Close() + out := []AnalyticsUser{} + for rows.Next() { + var value AnalyticsUser + if err := rows.Scan(&value.UserID, &value.Username, &value.Events, &value.Journeys, &value.LastActiveAt); err != nil { + return nil, err + } + out = append(out, value) + } + return out, rows.Err() +} + +func (s *Store) UserFeatureStats(ctx context.Context, userID string, since time.Time) ([]FeatureStat, error) { + rows, err := s.pool.Query(ctx, ` + SELECT feature, count(*), max(occurred_at) FROM journey_events + WHERE emby_user_id=$1 AND occurred_at >= $2 AND feature <> '' + AND action NOT IN ('screen_view', 'journey_start', 'journey_end') + GROUP BY feature ORDER BY count(*) DESC, feature`, userID, since) + if err != nil { + return nil, fmt.Errorf("store: user feature stats: %w", err) + } + defer rows.Close() + out := []FeatureStat{} + for rows.Next() { + var v FeatureStat + if err := rows.Scan(&v.Feature, &v.Uses, &v.LastUsedAt); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +func (s *Store) UserPaths(ctx context.Context, userID string, since time.Time) ([]PathStat, error) { + rows, err := s.pool.Query(ctx, ` + WITH ordered AS ( + SELECT id, journey_id, sequence, action, occurred_at, + coalesce(nullif(target,''), nullif(screen,''), feature) AS node, + lag(coalesce(nullif(target,''), nullif(screen,''), feature)) OVER + (PARTITION BY journey_id ORDER BY sequence, occurred_at, id) AS previous + FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2 + ), path_steps AS ( + SELECT previous AS from_node, node AS to_node FROM ordered + WHERE previous IS NOT NULL AND node IS NOT NULL AND previous <> node + ), last_steps AS ( + SELECT DISTINCT ON (journey_id) journey_id, node, action, occurred_at + FROM ordered ORDER BY journey_id, sequence DESC, occurred_at DESC, id DESC + ), all_steps AS ( + SELECT from_node, to_node FROM path_steps + UNION ALL + SELECT node, 'abandoned' FROM last_steps + WHERE action <> 'journey_end' AND node <> '' + AND occurred_at < now() - interval '30 minutes' + ) + SELECT from_node, to_node, count(*) FROM all_steps + GROUP BY from_node, to_node ORDER BY count(*) DESC, from_node, to_node LIMIT 20`, userID, since) + if err != nil { + return nil, fmt.Errorf("store: user paths: %w", err) + } + defer rows.Close() + out := []PathStat{} + for rows.Next() { + var v PathStat + if err := rows.Scan(&v.From, &v.To, &v.Count); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time.Time, limit int) ([]JourneyEvent, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action, + screen, feature, source, target, item_id, item_type, outcome + FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2 + ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit) + if err != nil { + return nil, fmt.Errorf("store: user journey events: %w", err) + } + defer rows.Close() + out := []JourneyEvent{} + for rows.Next() { + var v JourneyEvent + if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemID, &v.ItemType, &v.Outcome); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + // RowStats aggregates engagement since a point in time, busiest row first. // // Dwell is the interesting number: impressions only say a row was on screen, whereas @@ -182,11 +352,14 @@ func (s *Store) RowStats(ctx context.Context, since time.Time) ([]RowStat, error // at read time, so nothing is preserved once the events go — which is the point: this is // engagement telemetry for tuning rows, not a permanent record of what people watched. func (s *Store) PruneRowEvents(ctx context.Context, olderThan time.Duration) (int64, error) { - tag, err := s.pool.Exec(ctx, - `DELETE FROM row_events WHERE occurred_at < now() - $1::interval`, - fmt.Sprintf("%d seconds", int64(olderThan.Seconds()))) + interval := fmt.Sprintf("%d seconds", int64(olderThan.Seconds())) + rows, err := s.pool.Exec(ctx, `DELETE FROM row_events WHERE occurred_at < now() - $1::interval`, interval) if err != nil { return 0, err } - return tag.RowsAffected(), nil + journeys, err := s.pool.Exec(ctx, `DELETE FROM journey_events WHERE occurred_at < now() - $1::interval`, interval) + if err != nil { + return rows.RowsAffected(), err + } + return rows.RowsAffected() + journeys.RowsAffected(), nil } diff --git a/server/internal/store/schema.sql b/server/internal/store/schema.sql index 24dce7e..dc518ba 100644 --- a/server/internal/store/schema.sql +++ b/server/internal/store/schema.sql @@ -155,6 +155,34 @@ CREATE TABLE IF NOT EXISTS row_events ( CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC); CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC); +-- Significant, user-scoped app journeys. Values are deliberately categorical: content +-- names, search terms, setting values and other free text do not belong in this table. +-- journey_id is generated by the client for one foreground visit; emby_user_id is always +-- taken from the authenticated gateway session rather than trusted from the payload. +CREATE TABLE IF NOT EXISTS journey_events ( + id BIGSERIAL PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + emby_user_id TEXT NOT NULL, + journey_id TEXT NOT NULL, + sequence INT NOT NULL DEFAULT 0, + category TEXT NOT NULL, + action TEXT NOT NULL, + screen TEXT NOT NULL DEFAULT '', + feature TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + target TEXT NOT NULL DEFAULT '', + item_id TEXT NOT NULL DEFAULT '', + item_type TEXT NOT NULL DEFAULT '', + outcome TEXT NOT NULL DEFAULT '' +); + +CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx + ON journey_events (emby_user_id, journey_id, sequence); +CREATE INDEX IF NOT EXISTS journey_events_user_time_idx + ON journey_events (emby_user_id, occurred_at DESC); +CREATE INDEX IF NOT EXISTS journey_events_feature_time_idx + ON journey_events (feature, occurred_at DESC); + -- Search terms are retained separately from row engagement so they can inform future -- ranking/recommendation work without coupling that analysis to rendered rows. CREATE TABLE IF NOT EXISTS search_history (