0.2.45 - Advanced analytics, logout old versions
This commit is contained in:
@@ -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
|
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 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
|
**Baseline profile.** `androidx.profileinstaller` plus a profile generated by
|
||||||
`benchmark/BaselineProfileGenerator.kt`. Regenerate against a real television with
|
`benchmark/BaselineProfileGenerator.kt`. Regenerate against a real television with
|
||||||
`.\gradlew.bat :app:generateReleaseBaselineProfile`; the result is checked in under
|
`.\gradlew.bat :app:generateReleaseBaselineProfile`; the result is checked in under
|
||||||
|
|||||||
@@ -55,11 +55,6 @@ The Media3 software audio fallback uses Jellyfin's FFmpeg decoder extension:
|
|||||||
|
|
||||||
https://github.com/jellyfin/jellyfin-androidx-media
|
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
|
Memby is an independent project and is not affiliated with or endorsed by
|
||||||
Emby LLC. Emby is a trademark of Emby LLC.
|
Emby LLC. Emby is a trademark of Emby LLC.
|
||||||
|
|
||||||
|
|||||||
+18
-6
@@ -42,7 +42,7 @@ val projectNoticeText =
|
|||||||
|
|
||||||
// A release workflow can derive the app version from its Git tag without editing the
|
// 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.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.43"
|
val defaultVersionName = "0.2.45"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
@@ -79,6 +79,15 @@ android {
|
|||||||
versionCode = membyVersionCode
|
versionCode = membyVersionCode
|
||||||
versionName = membyVersionName
|
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", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
|
||||||
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
|
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
|
||||||
buildConfigField("String", "SOURCE_CODE_URL", buildConfigString(membySourceUrl))
|
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
|
// 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
|
// 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.
|
// 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")
|
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")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
// Ahead-of-time compiles the startup + first-scroll path. Regenerate against a real
|
// Ahead-of-time compiles the startup + first-scroll path. Regenerate against a real
|
||||||
|
|||||||
Vendored
+11
-5
@@ -34,15 +34,21 @@
|
|||||||
-keep,allowobfuscation interface com.ponzischeme89.memby.data.remote.GatewayApi
|
-keep,allowobfuscation interface com.ponzischeme89.memby.data.remote.GatewayApi
|
||||||
-keepattributes Exceptions
|
-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 -------------------------------------------------------------
|
# --- Media3 / coroutines -------------------------------------------------------------
|
||||||
-dontwarn androidx.media3.**
|
-dontwarn androidx.media3.**
|
||||||
-dontwarn kotlinx.coroutines.**
|
-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 ---------------------------------------------------------------
|
# --- Crash readability ---------------------------------------------------------------
|
||||||
# Releases are self-hosted with no crash reporter, so a stack trace read off a TV over
|
# 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
|
# adb is the only diagnostic there is. Line numbers cost a little dex size and are worth
|
||||||
|
|||||||
@@ -117,16 +117,6 @@
|
|||||||
android:theme="@style/Theme.Memby.Fullscreen"
|
android:theme="@style/Theme.Memby.Fullscreen"
|
||||||
tools:ignore="DiscouragedApi" />
|
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).
|
<!-- The system screensaver (Daydream / Ambient mode source).
|
||||||
Interactive: select to open the panel, play, or favourite. -->
|
Interactive: select to open the panel, play, or favourite. -->
|
||||||
<service
|
<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.GatewayPrerollSchedule
|
||||||
import com.ponzischeme89.memby.data.model.GatewayRowEvent
|
import com.ponzischeme89.memby.data.model.GatewayRowEvent
|
||||||
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
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.GatewayServiceStatus
|
||||||
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
|
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
|
||||||
import com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest
|
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
|
* Optional decoration for the player pre-roll. Failure or direct-to-Emby mode returns
|
||||||
* an empty schedule immediately; playback never depends on this request.
|
* 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>,
|
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
|
@Serializable
|
||||||
data class GatewayPlaybackReport(
|
data class GatewayPlaybackReport(
|
||||||
val itemId: String,
|
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.GatewayPlaybackReport
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
||||||
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
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.GatewayRows
|
||||||
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
|
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
|
||||||
import com.ponzischeme89.memby.data.model.GatewayRequestLookup
|
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. */
|
/** Row engagement, uploaded in batches. Fire-and-forget: failures are not retried. */
|
||||||
@POST("v1/analytics/rows")
|
@POST("v1/analytics/rows")
|
||||||
suspend fun reportRowEvents(@Body body: GatewayRowEvents)
|
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.HomeCache
|
||||||
import com.ponzischeme89.memby.data.HomeSnapshot
|
import com.ponzischeme89.memby.data.HomeSnapshot
|
||||||
import com.ponzischeme89.memby.data.analytics.RowAnalytics
|
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.friendlyEmbyError
|
||||||
import com.ponzischeme89.memby.data.isMaintenanceError
|
import com.ponzischeme89.memby.data.isMaintenanceError
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
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. */
|
/** Row engagement, buffered here and uploaded in batches. */
|
||||||
private val analytics = RowAnalytics()
|
private val analytics = RowAnalytics()
|
||||||
|
private val journey = JourneyAnalytics(repository.currentSettings.userId.orEmpty())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refreshAll()
|
refreshAll()
|
||||||
@@ -193,6 +195,25 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
fun flushAnalytics() {
|
fun flushAnalytics() {
|
||||||
analytics.endFocus()
|
analytics.endFocus()
|
||||||
repository.reportRowEvents(analytics.drain())
|
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) {
|
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.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.saveable.listSaver
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -1829,6 +1830,10 @@ private fun HomeScreen(
|
|||||||
val playbackLauncher = rememberLauncherForActivityResult(
|
val playbackLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.StartActivityForResult(),
|
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
|
// 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
|
// 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.
|
// focus, especially when playback progress refreshed the row behind the player.
|
||||||
@@ -1869,6 +1874,12 @@ private fun HomeScreen(
|
|||||||
}
|
}
|
||||||
val playItem: (BaseItem) -> Unit = playItem@{ item ->
|
val playItem: (BaseItem) -> Unit = playItem@{ item ->
|
||||||
if (launchingItem != null || !item.membyPlayable) return@playItem
|
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
|
launchingItem = item
|
||||||
val playbackRequestedAtMs = SystemClock.elapsedRealtime()
|
val playbackRequestedAtMs = SystemClock.elapsedRealtime()
|
||||||
// Resuming: open the player now and let it resolve the stream while it starts.
|
// Resuming: open the player now and let it resolve the stream while it starts.
|
||||||
@@ -1981,6 +1992,22 @@ private fun HomeScreen(
|
|||||||
repo.markForYouOpened()
|
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) {
|
LaunchedEffect(showForYouNudge, selectedDestination, settings.hasOpenedForYou) {
|
||||||
if (
|
if (
|
||||||
showForYouNudge &&
|
showForYouNudge &&
|
||||||
@@ -2018,7 +2045,7 @@ private fun HomeScreen(
|
|||||||
lifecycleOwner.lifecycle.addObserver(observer)
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
onDispose {
|
onDispose {
|
||||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
homeViewModel.flushAnalytics()
|
homeViewModel.endJourney(latestJourneyScreen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2037,6 +2064,11 @@ private fun HomeScreen(
|
|||||||
navigationExpanded = it
|
navigationExpanded = it
|
||||||
},
|
},
|
||||||
onDestinationSelected = { destination ->
|
onDestinationSelected = { destination ->
|
||||||
|
homeViewModel.trackJourney(
|
||||||
|
category = "navigation", action = "select",
|
||||||
|
screen = journeyScreen, feature = destination.name.lowercase(),
|
||||||
|
source = journeyScreen, target = destination.name.lowercase(),
|
||||||
|
)
|
||||||
genreBrowseItemType = null
|
genreBrowseItemType = null
|
||||||
when (destination) {
|
when (destination) {
|
||||||
BrowseDestination.SETTINGS -> {
|
BrowseDestination.SETTINGS -> {
|
||||||
@@ -2136,6 +2168,12 @@ private fun HomeScreen(
|
|||||||
returnFocusRequester = cardReturnFocusRequester,
|
returnFocusRequester = cardReturnFocusRequester,
|
||||||
initialQuery = initialSearchQuery,
|
initialQuery = initialSearchQuery,
|
||||||
onInitialQueryConsumed = { initialSearchQuery = null },
|
onInitialQueryConsumed = { initialSearchQuery = null },
|
||||||
|
onSearchStarted = {
|
||||||
|
homeViewModel.trackJourney(
|
||||||
|
category = "search", action = "start", screen = "search",
|
||||||
|
feature = "search", source = "search", target = "search_results",
|
||||||
|
)
|
||||||
|
},
|
||||||
onItemFocused = homeViewModel::focusItem,
|
onItemFocused = homeViewModel::focusItem,
|
||||||
onItemSelected = { item ->
|
onItemSelected = { item ->
|
||||||
returnRowId = SEARCH_ROW_ID
|
returnRowId = SEARCH_ROW_ID
|
||||||
@@ -2143,6 +2181,11 @@ private fun HomeScreen(
|
|||||||
destinationFocus[BrowseDestination.SEARCH] = SEARCH_ROW_ID to item.id
|
destinationFocus[BrowseDestination.SEARCH] = SEARCH_ROW_ID to item.id
|
||||||
homeViewModel.focusItem(item)
|
homeViewModel.focusItem(item)
|
||||||
if (item.membyPlayable) {
|
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
|
detailsAiringNotice = null
|
||||||
detailsItem = item
|
detailsItem = item
|
||||||
}
|
}
|
||||||
@@ -2175,6 +2218,11 @@ private fun HomeScreen(
|
|||||||
returnRowId = GENRE_BROWSER_ROW_ID
|
returnRowId = GENRE_BROWSER_ROW_ID
|
||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
homeViewModel.focusItem(item)
|
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
|
detailsAiringNotice = null
|
||||||
detailsItem = item
|
detailsItem = item
|
||||||
},
|
},
|
||||||
@@ -2335,6 +2383,11 @@ private fun HomeScreen(
|
|||||||
entryFocusRequester = contentFocusRequester,
|
entryFocusRequester = contentFocusRequester,
|
||||||
onFocused = { navigationExpanded = false },
|
onFocused = { navigationExpanded = false },
|
||||||
onOpenCategory = { categoryId ->
|
onOpenCategory = { categoryId ->
|
||||||
|
homeViewModel.trackJourney(
|
||||||
|
category = "navigation", action = "open",
|
||||||
|
screen = selectedDestination.name.lowercase(), feature = "genre_browse",
|
||||||
|
source = "genre_strip", target = "genre_browser",
|
||||||
|
)
|
||||||
genreBrowseInitialCategoryId = categoryId
|
genreBrowseInitialCategoryId = categoryId
|
||||||
genreBrowseItemType = itemType
|
genreBrowseItemType = itemType
|
||||||
},
|
},
|
||||||
@@ -2355,7 +2408,14 @@ private fun HomeScreen(
|
|||||||
contentFocusRequester = contentFocusRequester.takeUnless {
|
contentFocusRequester = contentFocusRequester.takeUnless {
|
||||||
genreBrowserEnabled
|
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 },
|
onContentFocused = { navigationExpanded = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2370,6 +2430,10 @@ private fun HomeScreen(
|
|||||||
navigationFocusRequester = navigationFocusRequester,
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
onContentFocused = { navigationExpanded = false },
|
onContentFocused = { navigationExpanded = false },
|
||||||
onQuerySelected = { query ->
|
onQuerySelected = { query ->
|
||||||
|
homeViewModel.trackJourney(
|
||||||
|
category = "search", action = "select", screen = "favourites",
|
||||||
|
feature = "recent_searches", source = "recent_searches", target = "search",
|
||||||
|
)
|
||||||
initialSearchQuery = query
|
initialSearchQuery = query
|
||||||
selectedDestination = BrowseDestination.SEARCH
|
selectedDestination = BrowseDestination.SEARCH
|
||||||
},
|
},
|
||||||
@@ -2383,6 +2447,10 @@ private fun HomeScreen(
|
|||||||
loading = forYouState.loading,
|
loading = forYouState.loading,
|
||||||
error = forYouState.error,
|
error = forYouState.error,
|
||||||
onSelected = { minutes ->
|
onSelected = { minutes ->
|
||||||
|
homeViewModel.trackJourney(
|
||||||
|
category = "recommendations", action = "change", screen = "for_you",
|
||||||
|
feature = "for_you_time", outcome = "success",
|
||||||
|
)
|
||||||
homeViewModel.loadForYou(minutes)
|
homeViewModel.loadForYou(minutes)
|
||||||
scope.launch { repo.setForYouMinutes(minutes) }
|
scope.launch { repo.setForYouMinutes(minutes) }
|
||||||
},
|
},
|
||||||
@@ -2499,6 +2567,12 @@ private fun HomeScreen(
|
|||||||
returnRowId = row.id
|
returnRowId = row.id
|
||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
homeViewModel.trackRowSelected(row.id, row.kind.name, 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
|
// A schedule card is an episode that has not aired, so
|
||||||
// it is not playable and has no page of its own. What
|
// it is not playable and has no page of its own. What
|
||||||
// the viewer asked for is the show — carrying the air
|
// the viewer asked for is the show — carrying the air
|
||||||
@@ -2577,6 +2651,7 @@ private fun HomeScreen(
|
|||||||
if (profile.id != settings.activeProfileId) {
|
if (profile.id != settings.activeProfileId) {
|
||||||
switchingProfileId = profile.id
|
switchingProfileId = profile.id
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
|
||||||
runCatching { repo.switchProfile(profile) }
|
runCatching { repo.switchProfile(profile) }
|
||||||
.onFailure { switchingProfileId = null }
|
.onFailure { switchingProfileId = null }
|
||||||
}
|
}
|
||||||
@@ -2594,6 +2669,10 @@ private fun HomeScreen(
|
|||||||
},
|
},
|
||||||
alertCount = notificationState.notifications.size,
|
alertCount = notificationState.notifications.size,
|
||||||
onOpenAlerts = {
|
onOpenAlerts = {
|
||||||
|
homeViewModel.trackJourney(
|
||||||
|
category = "notifications", action = "open", screen = journeyScreen,
|
||||||
|
feature = "notifications", target = "notifications",
|
||||||
|
)
|
||||||
userSwitcherVisible = false
|
userSwitcherVisible = false
|
||||||
navigationExpanded = false
|
navigationExpanded = false
|
||||||
showNotifications = true
|
showNotifications = true
|
||||||
@@ -2653,6 +2732,12 @@ private fun HomeScreen(
|
|||||||
onClose = closeSettings,
|
onClose = closeSettings,
|
||||||
overlay = false,
|
overlay = false,
|
||||||
navigationFocusRequester = navigationFocusRequester,
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
|
onAnalyticsEvent = { feature, action ->
|
||||||
|
homeViewModel.trackJourney(
|
||||||
|
category = "settings", action = action, screen = "settings",
|
||||||
|
feature = feature, outcome = "success",
|
||||||
|
)
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.offset { IntOffset(x = settingsShift.roundToPx(), y = 0) },
|
.offset { IntOffset(x = settingsShift.roundToPx(), y = 0) },
|
||||||
@@ -2672,6 +2757,7 @@ private fun HomeScreen(
|
|||||||
} else {
|
} else {
|
||||||
switchingProfileId = profile.id
|
switchingProfileId = profile.id
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
|
||||||
runCatching { repo.switchProfile(profile) }
|
runCatching { repo.switchProfile(profile) }
|
||||||
.onFailure { switchingProfileId = null }
|
.onFailure { switchingProfileId = null }
|
||||||
}
|
}
|
||||||
@@ -2684,7 +2770,12 @@ private fun HomeScreen(
|
|||||||
removingProfileId = null
|
removingProfileId = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAddProfile = { addingProfile = true },
|
onAddProfile = {
|
||||||
|
scope.launch {
|
||||||
|
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
|
||||||
|
addingProfile = true
|
||||||
|
}
|
||||||
|
},
|
||||||
onClose = { showProfiles = false },
|
onClose = { showProfiles = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2714,6 +2805,11 @@ private fun HomeScreen(
|
|||||||
selected = selected,
|
selected = selected,
|
||||||
airingNotice = detailsAiringNotice,
|
airingNotice = detailsAiringNotice,
|
||||||
onOpenItem = { related ->
|
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
|
detailsTrail = detailsTrail + selected
|
||||||
// Ask for the full record the same way a focused card does. Some
|
// 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
|
// routes here hand over a stub rather than a row item — the episode
|
||||||
@@ -2733,9 +2829,21 @@ private fun HomeScreen(
|
|||||||
detailsAiringNotice = null
|
detailsAiringNotice = null
|
||||||
playItem(it)
|
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 },
|
isMyShow = myShows.any { it.itemId == selected.id },
|
||||||
onToggleMyShow = { item, saved ->
|
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
|
// 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
|
// 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
|
// *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 = {
|
onClose = {
|
||||||
detailsItem = null
|
detailsItem = null
|
||||||
detailsTrail = emptyList()
|
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
|
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. */
|
/** One prolonged mid-programme rebuffer gets a lower-risk H.264 stream. */
|
||||||
internal fun shouldRecoverProlongedRebuffer(
|
internal fun shouldRecoverProlongedRebuffer(
|
||||||
renderedFirstFrame: Boolean,
|
renderedFirstFrame: Boolean,
|
||||||
|
|||||||
@@ -143,7 +143,6 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private var prolongedRebufferRecoveryAttempted = false
|
private var prolongedRebufferRecoveryAttempted = false
|
||||||
private var automaticRetryAttempt = 0
|
private var automaticRetryAttempt = 0
|
||||||
private var renderedFirstFrame = false
|
private var renderedFirstFrame = false
|
||||||
private var handingOffToLibmpv = false
|
|
||||||
private var requestStartedAtMs = 0L
|
private var requestStartedAtMs = 0L
|
||||||
private var trace = PlaybackTrace(SystemClock.elapsedRealtime(), SystemClock::elapsedRealtime)
|
private var trace = PlaybackTrace(SystemClock.elapsedRealtime(), SystemClock::elapsedRealtime)
|
||||||
|
|
||||||
@@ -1238,41 +1237,9 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldUseLibmpvFallback(failure, automaticRetryAttempt) && handOffToLibmpv()) return
|
|
||||||
showPlaybackError(failure)
|
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) {
|
private fun retryPlayback(refreshSource: Boolean, forceTranscode: Boolean = false) {
|
||||||
retryJob?.cancel()
|
retryJob?.cancel()
|
||||||
prolongedRebufferJob?.cancel()
|
prolongedRebufferJob?.cancel()
|
||||||
@@ -3774,7 +3741,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
// this title resumes from — is the one the viewer had already skipped past.
|
// this title resumes from — is the one the viewer had already skipped past.
|
||||||
commitSeek()
|
commitSeek()
|
||||||
player?.let {
|
player?.let {
|
||||||
if (!handingOffToLibmpv && playbackStarted && !stopReported) {
|
if (playbackStarted && !stopReported) {
|
||||||
reportProgress(it.currentPosition, isPaused = true, eventName = "Pause")
|
reportProgress(it.currentPosition, isPaused = true, eventName = "Pause")
|
||||||
stopReported = true
|
stopReported = true
|
||||||
stoppedInBackground = true
|
stoppedInBackground = true
|
||||||
@@ -3824,7 +3791,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
"bufferingMs=$totalBufferingMs positionMs=${player?.currentPosition ?: 0L}",
|
"bufferingMs=$totalBufferingMs positionMs=${player?.currentPosition ?: 0L}",
|
||||||
)
|
)
|
||||||
val playback = player
|
val playback = player
|
||||||
if (!handingOffToLibmpv && !stopReported && playbackStarted && !itemId.isNullOrBlank()) {
|
if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) {
|
||||||
stopReported = true
|
stopReported = true
|
||||||
PlaybackStopWorker.enqueue(
|
PlaybackStopWorker.enqueue(
|
||||||
this,
|
this,
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ fun SearchScreen(
|
|||||||
returnFocusRequester: FocusRequester,
|
returnFocusRequester: FocusRequester,
|
||||||
initialQuery: String? = null,
|
initialQuery: String? = null,
|
||||||
onInitialQueryConsumed: () -> Unit = {},
|
onInitialQueryConsumed: () -> Unit = {},
|
||||||
|
onSearchStarted: () -> Unit = {},
|
||||||
onItemFocused: (BaseItem) -> Unit,
|
onItemFocused: (BaseItem) -> Unit,
|
||||||
onItemSelected: (BaseItem) -> Unit,
|
onItemSelected: (BaseItem) -> Unit,
|
||||||
onContentFocused: () -> Unit,
|
onContentFocused: () -> Unit,
|
||||||
@@ -194,6 +195,15 @@ fun SearchScreen(
|
|||||||
var lastKeyIndex by remember { mutableIntStateOf(0) }
|
var lastKeyIndex by remember { mutableIntStateOf(0) }
|
||||||
var focusInResults by remember { mutableStateOf(false) }
|
var focusInResults by remember { mutableStateOf(false) }
|
||||||
var restoreGenreChipFocus 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 {
|
val hasResultsTarget = when {
|
||||||
state.errorMessage != null && state.results.isEmpty() -> true
|
state.errorMessage != null && state.results.isEmpty() -> true
|
||||||
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ fun SettingsSheet(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
overlay: Boolean = false,
|
overlay: Boolean = false,
|
||||||
navigationFocusRequester: FocusRequester? = null,
|
navigationFocusRequester: FocusRequester? = null,
|
||||||
|
onAnalyticsEvent: (feature: String, action: String) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val store = ServiceLocator.settings
|
val store = ServiceLocator.settings
|
||||||
@@ -496,30 +497,37 @@ fun SettingsSheet(
|
|||||||
val actions = SettingsPanelActions(
|
val actions = SettingsPanelActions(
|
||||||
onClose = onClose,
|
onClose = onClose,
|
||||||
onShowLogoChanged = {
|
onShowLogoChanged = {
|
||||||
|
onAnalyticsEvent("title_logo", "toggle")
|
||||||
showLogo = it
|
showLogo = it
|
||||||
scope.launch { store.setShowTitleLogo(it) }
|
scope.launch { store.setShowTitleLogo(it) }
|
||||||
},
|
},
|
||||||
onAutoPlayNextChanged = {
|
onAutoPlayNextChanged = {
|
||||||
|
onAnalyticsEvent("auto_play_next", "toggle")
|
||||||
autoPlayNext = it
|
autoPlayNext = it
|
||||||
scope.launch { store.setAutoPlayNextEpisode(it) }
|
scope.launch { store.setAutoPlayNextEpisode(it) }
|
||||||
},
|
},
|
||||||
onShowTenMinuteReminderChanged = {
|
onShowTenMinuteReminderChanged = {
|
||||||
|
onAnalyticsEvent("playback_reminder", "toggle")
|
||||||
showTenMinuteReminder = it
|
showTenMinuteReminder = it
|
||||||
scope.launch { store.setShowTenMinuteReminder(it) }
|
scope.launch { store.setShowTenMinuteReminder(it) }
|
||||||
},
|
},
|
||||||
onSeekIntervalChanged = {
|
onSeekIntervalChanged = {
|
||||||
|
onAnalyticsEvent("seek_interval", "change")
|
||||||
seekInterval = it
|
seekInterval = it
|
||||||
scope.launch { store.setSeekIntervalSeconds(it) }
|
scope.launch { store.setSeekIntervalSeconds(it) }
|
||||||
},
|
},
|
||||||
onSkipIntroModeChanged = {
|
onSkipIntroModeChanged = {
|
||||||
|
onAnalyticsEvent("skip_intro", "change")
|
||||||
skipIntroMode = it
|
skipIntroMode = it
|
||||||
scope.launch { store.setSkipIntroMode(it) }
|
scope.launch { store.setSkipIntroMode(it) }
|
||||||
},
|
},
|
||||||
onSpeedUpCreditsChanged = {
|
onSpeedUpCreditsChanged = {
|
||||||
|
onAnalyticsEvent("speed_up_credits", "toggle")
|
||||||
speedUpCredits = it
|
speedUpCredits = it
|
||||||
scope.launch { store.setSpeedUpCredits(it) }
|
scope.launch { store.setSpeedUpCredits(it) }
|
||||||
},
|
},
|
||||||
onAudioPassthroughModeChanged = { mode ->
|
onAudioPassthroughModeChanged = { mode ->
|
||||||
|
onAnalyticsEvent("audio_passthrough", "change")
|
||||||
audioPassthroughMode = mode
|
audioPassthroughMode = mode
|
||||||
// Moonfin seeds a first manual visit from the live probe. That makes Manual
|
// 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
|
// start as an editable copy of Auto instead of unexpectedly switching every
|
||||||
@@ -530,6 +538,7 @@ fun SettingsSheet(
|
|||||||
scope.launch { store.setAudioPassthrough(mode, audioPassthroughCodecs) }
|
scope.launch { store.setAudioPassthrough(mode, audioPassthroughCodecs) }
|
||||||
},
|
},
|
||||||
onAudioPassthroughCodecChanged = { codec, enabled ->
|
onAudioPassthroughCodecChanged = { codec, enabled ->
|
||||||
|
onAnalyticsEvent("audio_passthrough", "toggle")
|
||||||
audioPassthroughCodecs = if (enabled) {
|
audioPassthroughCodecs = if (enabled) {
|
||||||
audioPassthroughCodecs + codec
|
audioPassthroughCodecs + codec
|
||||||
} else {
|
} else {
|
||||||
@@ -540,18 +549,22 @@ fun SettingsSheet(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRingColorChanged = {
|
onRingColorChanged = {
|
||||||
|
onAnalyticsEvent("focus_colour", "change")
|
||||||
ringColor = it
|
ringColor = it
|
||||||
scope.launch { store.setRingColor(it) }
|
scope.launch { store.setRingColor(it) }
|
||||||
},
|
},
|
||||||
onHomeSectionChanged = { key, enabled ->
|
onHomeSectionChanged = { key, enabled ->
|
||||||
|
onAnalyticsEvent("home_sections", "toggle")
|
||||||
homeSections = if (enabled) homeSections + key else homeSections - key
|
homeSections = if (enabled) homeSections + key else homeSections - key
|
||||||
scope.launch { store.setHomeSections(homeSections.toList()) }
|
scope.launch { store.setHomeSections(homeSections.toList()) }
|
||||||
},
|
},
|
||||||
onCardDensityChanged = {
|
onCardDensityChanged = {
|
||||||
|
onAnalyticsEvent("card_density", "change")
|
||||||
cardDensity = it
|
cardDensity = it
|
||||||
scope.launch { store.setHomeCardDensity(it) }
|
scope.launch { store.setHomeCardDensity(it) }
|
||||||
},
|
},
|
||||||
onArtworkStyleChanged = {
|
onArtworkStyleChanged = {
|
||||||
|
onAnalyticsEvent("artwork_style", "change")
|
||||||
artworkStyle = it
|
artworkStyle = it
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Back closes this composable and cancels its scope. Once a selection has
|
// Back closes this composable and cancels its scope. Once a selection has
|
||||||
@@ -561,6 +574,7 @@ fun SettingsSheet(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRestoreHiddenRows = {
|
onRestoreHiddenRows = {
|
||||||
|
onAnalyticsEvent("hidden_rows", "change")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
store.setHomeRowPreferences(
|
store.setHomeRowPreferences(
|
||||||
settings.homeRowOrder.lineSequence().filter { it.isNotBlank() }.toList(),
|
settings.homeRowOrder.lineSequence().filter { it.isNotBlank() }.toList(),
|
||||||
@@ -570,22 +584,27 @@ fun SettingsSheet(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShowCardMetadataChanged = {
|
onShowCardMetadataChanged = {
|
||||||
|
onAnalyticsEvent("card_metadata", "toggle")
|
||||||
showCardMetadata = it
|
showCardMetadata = it
|
||||||
scope.launch { store.setShowHomeCardMetadata(it) }
|
scope.launch { store.setShowHomeCardMetadata(it) }
|
||||||
},
|
},
|
||||||
onShowRatingsStripChanged = {
|
onShowRatingsStripChanged = {
|
||||||
|
onAnalyticsEvent("ratings_strip", "toggle")
|
||||||
showRatingsStrip = it
|
showRatingsStrip = it
|
||||||
scope.launch { store.setShowRatingsStrip(it) }
|
scope.launch { store.setShowRatingsStrip(it) }
|
||||||
},
|
},
|
||||||
onHideWatchedMoviesChanged = {
|
onHideWatchedMoviesChanged = {
|
||||||
|
onAnalyticsEvent("hide_watched_movies", "toggle")
|
||||||
hideWatchedMovies = it
|
hideWatchedMovies = it
|
||||||
scope.launch { store.setHideWatchedMovies(it) }
|
scope.launch { store.setHideWatchedMovies(it) }
|
||||||
},
|
},
|
||||||
onConfirmExitMembyChanged = {
|
onConfirmExitMembyChanged = {
|
||||||
|
onAnalyticsEvent("confirm_exit", "toggle")
|
||||||
confirmExitMemby = it
|
confirmExitMemby = it
|
||||||
scope.launch { store.setConfirmExitMemby(it) }
|
scope.launch { store.setConfirmExitMemby(it) }
|
||||||
},
|
},
|
||||||
onThemeChanged = { chosen ->
|
onThemeChanged = { chosen ->
|
||||||
|
onAnalyticsEvent("theme", "change")
|
||||||
// No local echo: what is on screen is the palette the gateway resolves, and it
|
// 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
|
// 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,
|
// 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) }
|
scope.launch { store.setThemeId(chosen) }
|
||||||
},
|
},
|
||||||
onWelcomeQuoteStyleChanged = {
|
onWelcomeQuoteStyleChanged = {
|
||||||
|
onAnalyticsEvent("welcome_quote", "change")
|
||||||
welcomeQuoteStyle = it
|
welcomeQuoteStyle = it
|
||||||
scope.launch { store.setWelcomeQuoteStyle(it) }
|
scope.launch { store.setWelcomeQuoteStyle(it) }
|
||||||
},
|
},
|
||||||
onPageSelected = { selectedPage = it },
|
onPageSelected = {
|
||||||
|
onAnalyticsEvent("settings_page_${it.name.lowercase()}", "open")
|
||||||
|
selectedPage = it
|
||||||
|
},
|
||||||
onRefreshDevices = {
|
onRefreshDevices = {
|
||||||
deviceJob?.cancel()
|
deviceJob?.cancel()
|
||||||
deviceJob = scope.launch { refreshDevices() }
|
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 {
|
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
|
@Test
|
||||||
fun networkFailuresAreSafeToRetry() {
|
fun networkFailuresAreSafeToRetry() {
|
||||||
val failure = describePlaybackFailure(
|
val failure = describePlaybackFailure(
|
||||||
|
|||||||
@@ -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
|
`minimumVersion` can also be set directly for a staged rollout where the forced floor is
|
||||||
older than the newest build.
|
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`:
|
Two deliberate safeguards, both tested in `internal/appupdate`:
|
||||||
|
|
||||||
- A client that cannot report a version is **never** forced. It would otherwise be stuck
|
- A client that cannot report a version is **never** forced. It would otherwise be stuck
|
||||||
|
|||||||
@@ -309,9 +309,7 @@ func openStore(ctx context.Context, databaseURL string, log *slog.Logger) (*stor
|
|||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// pruneAnalytics keeps raw row events inside their retention window. The admin page
|
// pruneAnalytics keeps raw engagement and journey events inside their retention window.
|
||||||
// aggregates at read time, so nothing survives the prune — deliberately, since this is
|
|
||||||
// tuning telemetry rather than a permanent record of what anyone watched.
|
|
||||||
func pruneAnalytics(ctx context.Context, st *store.Store, retention time.Duration, log *slog.Logger) {
|
func pruneAnalytics(ctx context.Context, st *store.Store, retention time.Duration, log *slog.Logger) {
|
||||||
if retention <= 0 {
|
if retention <= 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -720,7 +720,29 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, "could not read analytics")
|
writeError(w, http.StatusInternalServerError, "could not read analytics")
|
||||||
return
|
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
|
// 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>
|
<label class="field narrow"><span>Window</span>
|
||||||
<select id="engagement-days">
|
<select id="engagement-days">
|
||||||
<option value="1">24 hours</option>
|
<option value="1">24 hours</option>
|
||||||
<option value="7" selected>7 days</option>
|
<option value="7">7 days</option>
|
||||||
<option value="30">30 days</option>
|
<option value="30" selected>30 days</option>
|
||||||
|
<option value="90">90 days</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -24,3 +25,53 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
|
|||||||
@@ -1,11 +1,81 @@
|
|||||||
const { fmt, ui, $ } = Admin;
|
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 () => {
|
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 || [];
|
const rows = payload.rows || [];
|
||||||
$('engagement-rows').innerHTML = rows.length ? rows.map((row) =>
|
$('engagement-rows').innerHTML = rows.length ? rows.map((row) =>
|
||||||
'<tr><td>' + fmt.escape(row.rowId) + '</td>' +
|
'<tr><td>' + fmt.escape(label(row.rowId)) + '</td>' +
|
||||||
'<td class="muted">' + fmt.escape(row.rowKind || '—') + '</td>' +
|
'<td class="muted">' + fmt.escape(label(row.rowKind)) + '</td>' +
|
||||||
'<td class="num">' + fmt.duration(row.dwellMs) + '</td>' +
|
'<td class="num">' + fmt.duration(row.dwellMs) + '</td>' +
|
||||||
'<td class="num">' + fmt.number(row.impressions) + '</td>' +
|
'<td class="num">' + fmt.number(row.impressions) + '</td>' +
|
||||||
'<td class="num">' + fmt.number(row.focuses) + '</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">' + Math.round((row.selectRate || 0) * 100) + '%</td>' +
|
||||||
'<td class="num">' + fmt.number(row.viewers) + '</td></tr>').join('')
|
'<td class="num">' + fmt.number(row.viewers) + '</td></tr>').join('')
|
||||||
: ui.emptyRow(8, 'No events in this window.');
|
: 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-days').addEventListener('change', Admin.refresh));
|
||||||
|
Admin.ready(() => $('engagement-user').addEventListener('change', Admin.refresh));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ponzischeme89/memby/server/internal/store"
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
@@ -30,6 +31,108 @@ type analyticsRequest struct {
|
|||||||
Events []rowEventPayload `json:"events"`
|
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.
|
// 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
|
// 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
|
return store.RowEvent{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
occurredAt := now
|
occurredAt := analyticsOccurredAt(payload.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dwell := payload.DwellMs
|
dwell := payload.DwellMs
|
||||||
if dwell < 0 {
|
if dwell < 0 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,7 +126,7 @@ func (s *Server) Routes() http.Handler {
|
|||||||
// once, without the gate ever touching health checks or the admin page.
|
// once, without the gate ever touching health checks or the admin page.
|
||||||
v1 := http.NewServeMux()
|
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("POST /v1/auth/logout", s.authed(s.handleLogout))
|
||||||
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
|
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
|
||||||
v1.Handle("GET /v1/auth/devices", s.authed(s.handleDevices))
|
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/playback/{phase}", s.authed(s.handlePlaybackReport))
|
||||||
v1.Handle("POST /v1/analytics/rows", s.authed(s.handleRowAnalytics))
|
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))
|
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)
|
mux.HandleFunc("GET /readyz", s.handleReady)
|
||||||
// Update policy is app-scoped, not user-scoped. Keep it outside authentication and
|
// 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
|
// 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.
|
// whether the server requires an update. A valid session enriches only its log context.
|
||||||
mux.HandleFunc("GET /v1/update", s.handleUpdate)
|
mux.Handle("GET /v1/update", s.identifyOptionalSession(http.HandlerFunc(s.handleUpdate)))
|
||||||
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
|
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
|
||||||
// status even while every normal /v1 operation is deliberately unavailable.
|
// status even while every normal /v1 operation is deliberately unavailable.
|
||||||
mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus))
|
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)
|
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.
|
// 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
|
// 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)
|
sess = s.captureClientIdentity(r, sess)
|
||||||
identify(r.Context(), 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)
|
h(w, r, sess)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ import (
|
|||||||
// speaks, next to the build's own version.
|
// speaks, next to the build's own version.
|
||||||
const ProtocolVersion = 1
|
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
|
// 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.
|
// database round trip per home load to answer "nothing to say" would be wasteful.
|
||||||
type updatePolicyCache struct {
|
type updatePolicyCache struct {
|
||||||
@@ -90,13 +96,54 @@ func compatibilityFor(r *http.Request) (bool, string) {
|
|||||||
return true, ""
|
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.
|
// handleUpdate answers the client's version check.
|
||||||
//
|
//
|
||||||
// Its own public endpoint rather than a field on /v1/home: update policy belongs to the
|
// 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
|
// app build, not a viewer or login. The verdict comes from memory; when a bearer token is
|
||||||
// the answer comes from memory, so checking it never reads or mutates a user session.
|
// 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) {
|
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
|
// 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
|
// checks on every launch, and "nothing to say" logged each time would bury the
|
||||||
// launch where an update was actually offered — or forced.
|
// launch where an update was actually offered — or forced.
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
||||||
"github.com/ponzischeme89/memby/server/internal/config"
|
"github.com/ponzischeme89/memby/server/internal/config"
|
||||||
|
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||||
"github.com/ponzischeme89/memby/server/internal/store"
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,3 +41,105 @@ func TestUpdateStatusIsPublicAndAvailableDuringMaintenance(t *testing.T) {
|
|||||||
t.Fatalf("status = %q, want mandatory", decision.Status)
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ type Config struct {
|
|||||||
SyncUserID string
|
SyncUserID string
|
||||||
SyncAPIKey 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
|
AnalyticsRetention time.Duration
|
||||||
|
|
||||||
// Sonarr is optional. When configured, its local calendar supplies the informational
|
// 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),
|
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
|
||||||
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
|
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
|
||||||
}
|
}
|
||||||
|
if c.AnalyticsRetention < 30*24*time.Hour {
|
||||||
|
c.AnalyticsRetention = 30 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
if c.EmbyURL == "" {
|
if c.EmbyURL == "" {
|
||||||
return c, fmt.Errorf("MEMBY_EMBY_URL is required")
|
return c, fmt.Errorf("MEMBY_EMBY_URL is required")
|
||||||
|
|||||||
@@ -63,3 +63,17 @@ func TestRecommendationWeightsMustBeJSON(t *testing.T) {
|
|||||||
t.Fatal("expected invalid recommendation weights to fail")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,45 @@ type RowEvent struct {
|
|||||||
DwellMs int
|
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
|
// 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.
|
// on it and for how long; select says something was opened from it.
|
||||||
const (
|
const (
|
||||||
@@ -141,6 +180,137 @@ func (s *Store) InsertRowEvents(ctx context.Context, events []RowEvent) error {
|
|||||||
return nil
|
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.
|
// 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
|
// 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
|
// 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.
|
// 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) {
|
func (s *Store) PruneRowEvents(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||||||
tag, err := s.pool.Exec(ctx,
|
interval := fmt.Sprintf("%d seconds", int64(olderThan.Seconds()))
|
||||||
`DELETE FROM row_events WHERE occurred_at < now() - $1::interval`,
|
rows, err := s.pool.Exec(ctx, `DELETE FROM row_events WHERE occurred_at < now() - $1::interval`, interval)
|
||||||
fmt.Sprintf("%d seconds", int64(olderThan.Seconds())))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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);
|
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
|
-- Search terms are retained separately from row engagement so they can inform future
|
||||||
-- ranking/recommendation work without coupling that analysis to rendered rows.
|
-- ranking/recommendation work without coupling that analysis to rendered rows.
|
||||||
CREATE TABLE IF NOT EXISTS search_history (
|
CREATE TABLE IF NOT EXISTS search_history (
|
||||||
|
|||||||
Reference in New Issue
Block a user