Memby v0.1.53: Android TV client plus gateway

Android TV client for Emby (Kotlin, Compose for TV) and the Memby gateway
(Go, Postgres, Redis) that fronts it.

Client:
- Setup, profiles, home rows, Media3 playback, system screensaver (Dream)
- Backend chosen at build time: gateway when memby.gatewayUrl is set,
  otherwise direct to Emby. Both paths stay working.
- Server-composed home rows, rendered verbatim so new row types ship
  without an app release
- Full-screen animated maintenance state, row engagement telemetry

Gateway:
- One request per TV screen; auth, caching, search and row shaping
- Library import from Emby into Postgres (manual, then hourly incremental)
- Recommendations from viewing history (recency-weighted genre affinity)
- Admin page for imports, an offline switch, and per-row analytics
- Video always direct-plays from Emby; only metadata passes through

Identity is com.ponzischeme89.memby throughout, replacing
com.mattcohen.embyclientsname. A changed applicationId installs as a new
app: TVs need a fresh sign-in and the old package uninstalled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-07-27 08:16:20 +12:00
co-authored by Claude Opus 5
commit 2ce405c540
99 changed files with 14433 additions and 0 deletions
@@ -0,0 +1,109 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayPlayback
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Contract tests for the gateway wire format.
*
* The fixtures below are the exact shape `server/internal/api` emits: camelCase envelope
* fields wrapping Emby's own PascalCase item JSON. If someone renames a field on either
* side, this fails before a TV ever sees it.
*/
class GatewayPayloadTest {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
isLenient = true
explicitNulls = false
}
@Test
fun `decodes a home payload with emby-shaped items`() {
val payload = """
{
"continueWatching": [
{"Id":"1","Name":"Dune","Type":"Movie","UserData":{"PlaybackPositionTicks":36000000000}}
],
"nextUp": [
{"Id":"2","Name":"Pilot","Type":"Episode","SeriesName":"Severance"}
],
"favorites": [
{"Id":"3","Name":"Arrival","Type":"Movie","UserData":{"IsFavorite":true}}
],
"latestMovies": [],
"partial": false
}
""".trimIndent()
val home = json.decodeFromString<GatewayHome>(payload)
assertEquals("Dune", home.continueWatching.single().name)
assertEquals(3_600_000L, home.continueWatching.single().userData!!.playbackPositionTicks / 10_000L)
assertEquals("Severance", home.nextUp.single().seriesName)
assertTrue(home.favorites.single().isFavorite)
assertTrue(home.latestMovies.isEmpty())
assertEquals(false, home.partial)
}
@Test
fun `decodes server-composed rows including recommendations`() {
val payload = """
{
"rows": [
{"id":"continue","title":"Continue Watching","kind":"continue","items":[{"Id":"1","Name":"Dune","Type":"Movie"}]},
{"id":"favorites","title":"Favourites","kind":"favorites","items":[{"Id":"3","Name":"Arrival","Type":"Movie"}]},
{"id":"similar:sev","title":"Because you watched Severance","kind":"similar","items":[{"Id":"7","Name":"Devs","Type":"Series"}]},
{"id":"recommended","title":"Recommended from your watching history","kind":"recommended","items":[{"Id":"8","Name":"Solaris","Type":"Movie"}]}
],
"continueWatching": [{"Id":"1","Name":"Dune","Type":"Movie"}],
"nextUp": [],
"favorites": [{"Id":"3","Name":"Arrival","Type":"Movie"}],
"latestMovies": [],
"partial": false
}
""".trimIndent()
val home = json.decodeFromString<GatewayHome>(payload)
assertEquals(
listOf("continue", "favorites", "similar:sev", "recommended"),
home.rows.map { it.id },
)
assertEquals("Because you watched Severance", home.rows[2].title)
assertEquals("Solaris", home.rows.last().items.single().name)
}
@Test
fun `a home payload without rows still decodes`() {
// The gateway omits recommendation rows while they are still building, and an
// older gateway would not send `rows` at all.
val home = json.decodeFromString<GatewayHome>(
"""{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":false}""",
)
assertTrue(home.rows.isEmpty())
}
@Test
fun `a partial home payload still decodes`() {
val home = json.decodeFromString<GatewayHome>(
"""{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":true}""",
)
assertTrue(home.partial)
}
@Test
fun `decodes a playback response`() {
val playback = json.decodeFromString<GatewayPlayback>(
"""{"itemId":"9","title":"Severance Pilot","url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""",
)
assertEquals("9", playback.itemId)
assertEquals(42_000L, playback.resumePositionMs)
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
}
}
@@ -0,0 +1,55 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The gateway's 503 body is the only thing allowed to put words on the maintenance
* screen, so what gets trusted out of it matters.
*/
class MaintenanceMessageTest {
@Test
fun `reads the operator's message`() {
val body = """{"error":"Back at 9pm","maintenance":true,"message":"Back at 9pm"}"""
assertEquals("Back at 9pm", parseMaintenanceMessage(body))
}
@Test
fun `ignores a 503 that is not a maintenance response`() {
// Some proxy or upstream returning its own 503 must not get to write on screen.
assertNull(parseMaintenanceMessage("""{"message":"upstream connect error"}"""))
assertNull(parseMaintenanceMessage("""{"maintenance":false,"message":"nope"}"""))
}
@Test
fun `survives a body that is not the shape we expect`() {
assertNull(parseMaintenanceMessage(""))
assertNull(parseMaintenanceMessage(" "))
assertNull(parseMaintenanceMessage("<html>502 Bad Gateway</html>"))
assertNull(parseMaintenanceMessage("""{"maintenance":true"""))
}
@Test
fun `blank and whitespace-only messages are rejected`() {
assertNull(parseMaintenanceMessage("""{"maintenance":true,"message":" "}"""))
assertNull(parseMaintenanceMessage("""{"maintenance":true}"""))
}
@Test
fun `an over-long message is truncated to something that fits a screen`() {
val long = "x".repeat(400)
val parsed = parseMaintenanceMessage("""{"maintenance":true,"message":"$long"}""")
assertEquals(160, parsed?.length)
}
@Test
fun `unknown fields from a newer gateway are ignored`() {
val body = """{"maintenance":true,"message":"Upgrading","until":"2026-07-27T22:00:00Z"}"""
assertEquals("Upgrading", parseMaintenanceMessage(body))
}
}
@@ -0,0 +1,16 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Test
class PlaybackReportMathTest {
@Test
fun millisecondsAreConvertedToEmbyTicks() {
assertEquals(12_340_000L, millisecondsToTicks(1_234L))
}
@Test
fun negativePositionsAreClamped() {
assertEquals(0L, millisecondsToTicks(-1L))
}
}
@@ -0,0 +1,39 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ProfileSettingsTest {
private val profile = EmbyProfile(
id = "server::user",
serverUrl = "http://emby",
token = "token",
userId = "user",
username = "Matt",
)
@Test
fun `active profile matches both server and user`() {
val settings = Settings(
serverUrl = profile.serverUrl,
token = profile.token,
userId = profile.userId,
profiles = listOf(profile),
)
assertEquals(profile.id, settings.activeProfileId)
}
@Test
fun `profile from another server is not treated as active`() {
val settings = Settings(
serverUrl = "http://another-server",
token = profile.token,
userId = profile.userId,
profiles = listOf(profile),
)
assertNull(settings.activeProfileId)
}
}
@@ -0,0 +1,141 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.analytics.RowAnalytics
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class RowAnalyticsTest {
/** A controllable clock, so dwell assertions are exact rather than timing-dependent. */
private class FakeClock(var millis: Long = 1_700_000_000_000) {
fun advance(by: Long) { millis += by }
}
private fun analytics(clock: FakeClock, maxBuffered: Int = 200) =
RowAnalytics(now = { clock.millis }, maxBuffered = maxBuffered)
@Test
fun `dwell is measured when focus leaves a row`() {
val clock = FakeClock()
val collector = analytics(clock)
collector.rowFocused("recommended", "MOVIES", "item-1")
clock.advance(5_000)
collector.rowFocused("favorites", "FAVORITES", "item-2")
val focus = collector.drain().single { it.event == RowAnalytics.EVENT_FOCUS }
assertEquals("recommended", focus.rowId)
assertEquals(5_000L, focus.dwellMs)
assertEquals("item-1", focus.itemId)
}
@Test
fun `moving between cards inside one row keeps measuring the same dwell`() {
val clock = FakeClock()
val collector = analytics(clock)
collector.rowFocused("recommended", "MOVIES", "item-1")
clock.advance(3_000)
collector.rowFocused("recommended", "MOVIES", "item-2")
clock.advance(3_000)
collector.endFocus()
val focuses = collector.drain().filter { it.event == RowAnalytics.EVENT_FOCUS }
assertEquals(1, focuses.size)
assertEquals(6_000L, focuses.single().dwellMs)
}
@Test
fun `passing through a row is not counted as attention`() {
val clock = FakeClock()
val collector = analytics(clock)
collector.rowFocused("continue", "CONTINUE", "a")
clock.advance(RowAnalytics.MIN_DWELL_MS - 1)
collector.rowFocused("favorites", "FAVORITES", "b")
assertTrue(
"a sub-threshold glance should produce no focus event",
collector.drain().none { it.event == RowAnalytics.EVENT_FOCUS },
)
}
@Test
fun `an impression is recorded once per row`() {
val collector = analytics(FakeClock())
collector.rowImpression("favorites", "FAVORITES")
collector.rowImpression("favorites", "FAVORITES")
collector.rowImpression("recommended", "MOVIES")
val impressions = collector.drain().filter { it.event == RowAnalytics.EVENT_IMPRESSION }
assertEquals(listOf("favorites", "recommended"), impressions.map { it.rowId })
}
@Test
fun `focusing a row that was never reported still records the impression`() {
val collector = analytics(FakeClock())
collector.rowFocused("similar:sev", "MOVIES", "item-1")
val events = collector.drain()
assertEquals(1, events.count { it.event == RowAnalytics.EVENT_IMPRESSION })
assertEquals("similar:sev", events.single().rowId)
}
@Test
fun `selections are recorded with their item`() {
val collector = analytics(FakeClock())
collector.rowSelected("recommended", "MOVIES", "item-9")
val select = collector.drain().single()
assertEquals(RowAnalytics.EVENT_SELECT, select.event)
assertEquals("item-9", select.itemId)
assertEquals("MOVIES", select.rowKind)
}
@Test
fun `draining clears the buffer`() {
val collector = analytics(FakeClock())
collector.rowImpression("favorites", "FAVORITES")
assertEquals(1, collector.drain().size)
assertEquals(0, collector.drain().size)
}
@Test
fun `the buffer is bounded and keeps the most recent events`() {
val collector = analytics(FakeClock(), maxBuffered = 3)
repeat(6) { collector.rowImpression("row-$it", "MOVIES") }
val events = collector.drain()
assertEquals(3, events.size)
assertEquals(listOf("row-3", "row-4", "row-5"), events.map { it.rowId })
}
@Test
fun `timestamps are sent in the format the gateway parses`() {
val collector = analytics(FakeClock())
collector.rowImpression("favorites", "FAVORITES")
val occurredAt = collector.drain().single().occurredAt
assertTrue(
"expected RFC3339 UTC, got $occurredAt",
occurredAt.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z""")),
)
}
@Test
fun `reset forgets buffered events and seen rows`() {
val collector = analytics(FakeClock())
collector.rowImpression("favorites", "FAVORITES")
collector.reset()
collector.rowImpression("favorites", "FAVORITES")
assertEquals(1, collector.drain().size)
}
}
@@ -0,0 +1,31 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ServerConfigTest {
@Test
fun `hardwired address wins over anything the user typed`() {
assertEquals(
"http://tv.example.com:8096",
resolveServerUrl("http://tv.example.com:8096/", "http://192.168.1.50:8096"),
)
}
@Test
fun `hardwired address is normalised like a typed one`() {
assertEquals("http://10.0.0.5:8096", resolveServerUrl("10.0.0.5:8096", ""))
}
@Test
fun `falls back to the typed address when the build pins nothing`() {
assertEquals("https://emby.example.com", resolveServerUrl(null, "https://emby.example.com/"))
assertEquals("https://emby.example.com", resolveServerUrl(" ", "https://emby.example.com/"))
}
@Test
fun `returns null when neither source supplies an address`() {
assertNull(resolveServerUrl(null, ""))
}
}
@@ -0,0 +1,39 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.HomeCache
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class HomeUiStateTest {
@Test
fun combinedWatchingRowPreservesOrderAndRemovesDuplicates() {
val resumable = BaseItem(id = "resume", name = "Resume")
val duplicate = BaseItem(id = "same", name = "Resume copy")
val next = BaseItem(id = "next", name = "Next")
val state = HomeUiState(
continueWatching = listOf(resumable, duplicate),
nextUp = listOf(duplicate.copy(name = "Next copy"), next),
)
assertEquals(listOf("resume", "same", "next"), state.watchingAndNextUp.map { it.id })
}
@Test
fun cachedContentIsShownWhileOnlyMissingRowsLoad() {
val cache = HomeCache(
continueWatching = listOf(BaseItem(id = "resume")),
favorites = listOf(BaseItem(id = "favorite")),
)
val state = HomeUiState.from(cache)
assertFalse(HomeSection.CONTINUE in state.loading)
assertTrue(HomeSection.NEXT_UP in state.loading)
assertFalse(HomeSection.FAVORITES in state.loading)
assertTrue(HomeSection.LATEST in state.loading)
}
}
@@ -0,0 +1,35 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaStream
import org.junit.Assert.assertEquals
import org.junit.Test
class MediaBadgesTest {
@Test
fun `derives premium video and audio badges without duplicates`() {
val item = BaseItem(
id = "movie",
mediaStreams = listOf(
MediaStream(
type = "Video",
codec = "hevc",
width = 3840,
videoRangeType = "DOVI",
title = "Dolby Vision HEVC",
),
MediaStream(type = "Audio", title = "TrueHD Dolby Atmos"),
),
)
assertEquals(
listOf("4K", "DOLBY VISION", "HEVC", "DOLBY ATMOS"),
mediaBadges(item),
)
}
@Test
fun `returns no badges when stream metadata is unavailable`() {
assertEquals(emptyList<String>(), mediaBadges(BaseItem(id = "unknown")))
}
}
@@ -0,0 +1,128 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.HomeCache
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The home screen is composed by the gateway. These pin the parts of that contract the
* client is responsible for: honouring the user's section toggles, always showing rows
* the server invented, and surviving a cold start from cache.
*/
class ServerHomeRowsTest {
private fun row(id: String, kind: String, vararg itemIds: String) = HomeRow(
id = id,
title = id.replaceFirstChar(Char::uppercase),
kind = kind,
items = itemIds.map { BaseItem(id = it) },
)
private val serverRows = listOf(
row("continue", "continue", "a"),
row("next-up", "nextup", "b"),
row("favorites", "favorites", "c"),
row("latest-movies", "latest", "d"),
row("similar:sev", "similar", "e"),
row("recommended", "recommended", "f"),
)
@Test
fun `server row order and titles are preserved`() {
val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), Settings())
assertEquals(
listOf("continue", "next-up", "favorites", "latest-movies", "similar:sev", "recommended"),
rows.map { it.id },
)
assertEquals("Recommended", rows.last().title)
}
@Test
fun `disabling a section hides its rows but never the recommendations`() {
val settings = Settings(homeSections = "continue")
val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), settings)
assertEquals(
listOf("continue", "next-up", "similar:sev", "recommended"),
rows.map { it.id },
)
}
@Test
fun `recommendation rows render as poster cards`() {
val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), Settings())
.associateBy { it.id }
assertEquals(MediaRowKind.CONTINUE, rows.getValue("continue").kind)
assertEquals(MediaRowKind.NEXT_UP, rows.getValue("next-up").kind)
assertEquals(MediaRowKind.FAVORITES, rows.getValue("favorites").kind)
assertEquals(MediaRowKind.MOVIES, rows.getValue("recommended").kind)
assertEquals(MediaRowKind.MOVIES, rows.getValue("similar:sev").kind)
}
@Test
fun `an unknown row kind from a newer server still renders`() {
val rows = serverHomeRows(
HomeUiState(rows = listOf(row("collection:halloween", "collection", "x")), loading = emptySet()),
Settings(),
)
assertEquals(1, rows.size)
assertEquals(MediaRowKind.MOVIES, rows.single().kind)
}
@Test
fun `only empty rows show a loading state while a refresh is running`() {
val rows = serverHomeRows(
HomeUiState(
rows = listOf(row("continue", "continue", "a"), row("recommended", "recommended")),
loading = setOf(HomeSection.CONTINUE),
),
Settings(),
).associateBy { it.id }
assertEquals(false, rows.getValue("continue").loading)
assertEquals(true, rows.getValue("recommended").loading)
}
@Test
fun `rows survive a round trip through the on-device cache`() {
val state = HomeUiState(rows = serverRows, loading = emptySet())
val encoded = Json.encodeToString(HomeCache.serializer(), state.toCache())
val restored = HomeUiState.from(Json.decodeFromString<HomeCache>(encoded))
assertEquals(serverRows.map { it.id }, restored.rows.map { it.id })
assertEquals("f", restored.rows.last().items.single().id)
}
@Test
fun `maintenance is a distinct state from an ordinary refresh error`() {
val offline = HomeUiState(rows = serverRows, maintenanceMessage = "Back at 9pm", hasRefreshError = true)
val slow = HomeUiState(rows = serverRows, hasRefreshError = true)
// The screen keys off maintenanceMessage; a slow connection must not trigger it.
assertEquals("Back at 9pm", offline.maintenanceMessage)
assertEquals(null, slow.maintenanceMessage)
// Rows survive underneath, so returning from maintenance does not start empty.
assertEquals(serverRows.size, serverHomeRows(offline, Settings()).size)
}
@Test
fun `a cache written before rows existed still decodes`() {
val legacy = """{"continueWatching":[{"Id":"a"}],"favorites":[],"nextUp":[],"latestMovies":[]}"""
val restored = HomeUiState.from(Json { ignoreUnknownKeys = true }.decodeFromString<HomeCache>(legacy))
assertTrue(restored.rows.isEmpty())
assertEquals("a", restored.continueWatching.single().id)
}
}
@@ -0,0 +1,39 @@
package com.ponzischeme89.memby.ui.screensaver
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Unit tests for the ring's pure geometry. The animation/lifecycle behaviour (progress
* reaching 100% after the slide duration, advancing exactly once, resetting on the next
* slide, restarting on manual navigation, and cancelling on teardown) is driven by
* Compose's [androidx.compose.animation.core.Animatable] + `repeatOnLifecycle` and is
* verified on-device against the DreamService path rather than here, since this module
* has no Compose UI-test / Robolectric harness.
*/
class SlideProgressMathTest {
private val tolerance = 0.0001f
@Test
fun beginsEmpty() {
assertEquals(0f, SlideProgressMath.sweepAngle(0f), tolerance)
}
@Test
fun reachesFullCircleAtCompletion() {
assertEquals(360f, SlideProgressMath.sweepAngle(1f), tolerance)
}
@Test
fun isProportionalMidway() {
assertEquals(180f, SlideProgressMath.sweepAngle(0.5f), tolerance)
assertEquals(90f, SlideProgressMath.sweepAngle(0.25f), tolerance)
}
@Test
fun clampsOutOfRangeValues() {
assertEquals(0f, SlideProgressMath.sweepAngle(-0.5f), tolerance)
assertEquals(360f, SlideProgressMath.sweepAngle(1.5f), tolerance)
}
}