0.2.45 - Advanced analytics, logout old versions

This commit is contained in:
ponzischeme89
2026-08-10 20:24:22 +12:00
parent 63f0768507
commit 56c1167382
32 changed files with 1125 additions and 452 deletions
+17
View File
@@ -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
-5
View File
@@ -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.
+18 -6
View File
@@ -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
+11 -5
View File
@@ -34,15 +34,21 @@
-keep,allowobfuscation interface com.ponzischeme89.memby.data.remote.GatewayApi
-keepattributes Exceptions
# --- Room (WorkManager's database) ---------------------------------------------------
# Room instantiates its generated `<Name>_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.<init> []` 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 { <init>(); }
# --- 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
-10
View File
@@ -117,16 +117,6 @@
android:theme="@style/Theme.Memby.Fullscreen"
tools:ignore="DiscouragedApi" />
<!-- libmpv is a last-resort codec/container fallback. It receives an already
resolved stream only after Media3 recovery has been exhausted. -->
<activity
android:name=".ui.player.MpvFallbackActivity"
android:exported="false"
android:screenOrientation="landscape"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|orientation"
android:theme="@style/Theme.Memby.Fullscreen"
tools:ignore="DiscouragedApi" />
<!-- The system screensaver (Daydream / Ambient mode source).
Interactive: select to open the panel, play, or favourite. -->
<service
@@ -10,6 +10,8 @@ import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewayRowEvent
import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayJourneyEvent
import com.ponzischeme89.memby.data.model.GatewayJourneyEvents
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
import com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest
@@ -1389,6 +1391,36 @@ class EmbyRepository(private val settings: SettingsStore) {
}
}
/** Uploads authenticated per-user journey events without affecting the UI. */
fun reportJourneyEvents(events: List<GatewayJourneyEvent>) {
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<GatewayRowEvent>,
journeyEvents: List<GatewayJourneyEvent>,
) {
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.
@@ -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<GatewayJourneyEvent>()
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<GatewayJourneyEvent> = 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<SimpleDateFormat>() {
override fun initialValue() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
}
}
}
@@ -720,6 +720,27 @@ data class GatewayRowEvents(
val events: List<GatewayRowEvent>,
)
/** 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<GatewayJourneyEvent>)
@Serializable
data class GatewayPlaybackReport(
val itemId: String,
@@ -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)
}
@@ -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) {
@@ -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()
@@ -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<PlayableSubtitle>,
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<PlayableSubtitle> =
encoded?.let {
runCatching { json.decodeFromString<List<PlayableSubtitle>>(it) }.getOrNull()
}.orEmpty()
}
}
@@ -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,
@@ -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,
@@ -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() ||
@@ -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() }
@@ -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" })
}
}
@@ -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(
+8
View File
@@ -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
+1 -3
View File
@@ -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
+23 -1
View File
@@ -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
@@ -8,8 +8,9 @@
<label class="field narrow"><span>Window</span>
<select id="engagement-days">
<option value="1">24 hours</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
<option value="7">7 days</option>
<option value="30" selected>30 days</option>
<option value="90">90 days</option>
</select></label>
</div>
<div class="table-wrap">
@@ -24,3 +25,53 @@
</table>
</div>
</section>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="people" data-icon-tone="info">User journeys</h2>
<p class="card-note">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.</p>
</div>
<label class="field narrow"><span>User</span>
<select id="engagement-user"><option value="">Choose a user</option></select></label>
</div>
<div class="tiles" id="engagement-user-tiles"></div>
</section>
<section class="card" id="engagement-feature-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="pulse" data-icon-tone="data">Feature use</h2>
<p class="card-note">Rare and unused features are shown explicitly against Memby's
major feature catalogue.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Feature</th><th class="num">Uses</th><th>Last used</th><th>Status</th></tr></thead>
<tbody id="engagement-features"></tbody>
</table></div>
</section>
<section class="card" id="engagement-path-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="list" data-icon-tone="note">Common paths</h2>
<p class="card-note">Repeated transitions reveal routes into playback and places a
viewer commonly leaves a flow.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>From</th><th>To</th><th class="num">Times</th></tr></thead>
<tbody id="engagement-paths"></tbody>
</table></div>
</section>
<section class="card" id="engagement-journey-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Chronological journey</h2>
<p class="card-note">Newest journeys first; actions within each journey run from start
to finish. A journey without an end event indicates an interruption or abandonment.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Time</th><th>Journey</th><th>Action</th><th>Screen / path</th><th>Feature</th><th>Content reference</th><th>Outcome</th></tr></thead>
<tbody id="engagement-events"></tbody>
</table></div>
</section>
+81 -3
View File
@@ -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 '<tr><td>' + fmt.escape(label(name)) + '</td><td class="num">' + fmt.number(uses) +
'</td><td class="muted">' + (stat ? fmt.when(stat.lastUsedAt) : '—') + '</td><td>' + status + '</td></tr>';
}).join('');
const paths = payload.paths || [];
$('engagement-paths').innerHTML = paths.length ? paths.map((path) =>
'<tr><td>' + fmt.escape(label(path.from)) + '</td><td>' + fmt.escape(label(path.to)) +
'</td><td class="num">' + fmt.number(path.count) + '</td></tr>').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('<tr><td class="muted">' + fmt.when(event.occurredAt) + '</td>' +
'<td class="num">' + number + '</td><td>' + fmt.escape(label(event.action)) + '</td>' +
'<td>' + fmt.escape(path) + '</td><td>' + fmt.escape(label(event.feature)) + '</td>' +
'<td class="muted">' + fmt.escape(content) + '</td><td>' + fmt.escape(label(event.outcome)) + '</td></tr>');
});
});
$('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) =>
'<tr><td>' + fmt.escape(row.rowId) + '</td>' +
'<td class="muted">' + fmt.escape(row.rowKind || '—') + '</td>' +
'<tr><td>' + fmt.escape(label(row.rowId)) + '</td>' +
'<td class="muted">' + fmt.escape(label(row.rowKind)) + '</td>' +
'<td class="num">' + fmt.duration(row.dwellMs) + '</td>' +
'<td class="num">' + fmt.number(row.impressions) + '</td>' +
'<td class="num">' + fmt.number(row.focuses) + '</td>' +
@@ -13,6 +83,14 @@ Admin.onRefresh(async () => {
'<td class="num">' + Math.round((row.selectRate || 0) * 100) + '%</td>' +
'<td class="num">' + fmt.number(row.viewers) + '</td></tr>').join('')
: ui.emptyRow(8, 'No events in this window.');
const users = payload.users || [];
const existing = $('engagement-user').value;
$('engagement-user').innerHTML = '<option value="">Choose a user</option>' + users.map((user) =>
'<option value="' + fmt.escape(user.userId) + '">' + fmt.escape(user.username || user.userId) + '</option>').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));
+104 -10
View File
@@ -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 {
+38
View File
@@ -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")
}
}
+39 -3
View File
@@ -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)
})
}
+50 -3
View File
@@ -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.
+105
View File
@@ -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"))
}
}
+5 -1
View File
@@ -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")
+14
View File
@@ -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)
}
}
+177 -4
View File
@@ -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
}
+28
View File
@@ -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 (