Server changes/Sonarr

This commit is contained in:
ponzischeme89
2026-07-27 21:06:51 +12:00
parent 62f6345a40
commit 8d6cf2f5a1
42 changed files with 2388 additions and 284 deletions
+21
View File
@@ -14,14 +14,28 @@ POSTGRES_PASSWORD=change-me
# Host port the gateway listens on. # Host port the gateway listens on.
MEMBY_PORT=8080 MEMBY_PORT=8080
# INFO is recommended. DEBUG also logs successful health, status and artwork requests.
MEMBY_LOG_LEVEL=INFO
# Used for "today" boundaries and human-readable Sonarr air/download times.
MEMBY_TIMEZONE=Pacific/Auckland
# How long a cached home payload stays warm. # How long a cached home payload stays warm.
MEMBY_HOME_TTL=60s MEMBY_HOME_TTL=60s
# Maximum number of distinct Memby TVs one Emby user may keep signed in.
MEMBY_MAX_CLIENTS_PER_USER=1
# Admin interface at http://<host>:8080/admin/ — library imports, the maintenance # Admin interface at http://<host>:8080/admin/ — library imports, the maintenance
# switch, and row analytics. Leave blank to disable /admin entirely. Generate with # switch, and row analytics. Leave blank to disable /admin entirely. Generate with
# openssl rand -hex 32 # openssl rand -hex 32
MEMBY_ADMIN_TOKEN= MEMBY_ADMIN_TOKEN=
# Public gateway address and a dedicated token used only by the Gitea release workflow.
# Generate the token with: openssl rand -hex 32
MEMBY_PUBLIC_URL=https://mserver.sublogue.com
MEMBY_RELEASE_PUBLISH_TOKEN=
# Library import. Hourly incremental keeps up with episodes added through the day. # Library import. Hourly incremental keeps up with episodes added through the day.
MEMBY_SYNC_INTERVAL=1h MEMBY_SYNC_INTERVAL=1h
MEMBY_SYNC_ON_START=false MEMBY_SYNC_ON_START=false
@@ -30,3 +44,10 @@ MEMBY_SYNC_ON_START=false
# recently active TV session, which works but stops if that user is removed. # recently active TV session, which works but stops if that user is removed.
#MEMBY_SYNC_USER_ID= #MEMBY_SYNC_USER_ID=
#MEMBY_SYNC_API_KEY= #MEMBY_SYNC_API_KEY=
# Optional Sonarr calendar integration. Use the URL reachable from this container,
# not necessarily the address entered in a browser. The API key is in Sonarr under
# Settings > General > Security.
#MEMBY_SONARR_URL=http://192.168.20.2:8989
#MEMBY_SONARR_API_KEY=
MEMBY_SONARR_TTL=5m
+16 -5
View File
@@ -15,6 +15,18 @@ val embyServerUrl: String = (project.findProperty("memby.serverUrl") as String?)
// becomes a thin renderer; blank keeps the direct-to-Emby path above. // becomes a thin renderer; blank keeps the direct-to-Emby path above.
val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as String?).orEmpty().trim() val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as String?).orEmpty().trim()
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.1.54"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
?.takeIf { it.matches(Regex("""\d+\.\d+\.\d+""")) }
?: defaultVersionName
val membyVersionParts = membyVersionName.split('.').map(String::toInt)
val membyVersionCode =
membyVersionParts[0] * 10_000 + membyVersionParts[1] * 100 + membyVersionParts[2]
/** Reads a key from local.properties, which is gitignored and holds machine secrets. */ /** Reads a key from local.properties, which is gitignored and holds machine secrets. */
val localProperties = Properties().apply { val localProperties = Properties().apply {
val file = rootProject.file("local.properties") val file = rootProject.file("local.properties")
@@ -37,11 +49,10 @@ android {
applicationId = "com.ponzischeme89.memby" applicationId = "com.ponzischeme89.memby"
minSdk = 23 minSdk = 23
targetSdk = 35 targetSdk = 35
// versionCode is derived from versionName: major*10000 + minor*100 + patch. // Derived from one version string so CI cannot publish a versionName/versionCode
// 0.1.53 -> 153. Keep them in step; the in-app updater compares versionName, // pair that Android later refuses to install.
// but Android will not install an APK whose versionCode went backwards. versionCode = membyVersionCode
versionCode = 153 versionName = membyVersionName
versionName = "0.1.53"
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("\"", "\\\"")}\"")
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby
import android.content.Context import android.content.Context
import com.ponzischeme89.memby.data.EmbyRepository import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.SettingsStore import com.ponzischeme89.memby.data.SettingsStore
/** /**
@@ -14,10 +15,13 @@ object ServiceLocator {
private set private set
lateinit var repository: EmbyRepository lateinit var repository: EmbyRepository
private set private set
lateinit var maintenance: MaintenanceMonitor
private set
fun init(context: Context) { fun init(context: Context) {
if (::repository.isInitialized) return if (::repository.isInitialized) return
settings = SettingsStore(context.applicationContext) settings = SettingsStore(context.applicationContext)
repository = EmbyRepository(settings) repository = EmbyRepository(settings)
maintenance = MaintenanceMonitor(repository)
} }
} }
@@ -3,10 +3,13 @@ package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.AuthRequest import com.ponzischeme89.memby.data.model.AuthRequest
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayFlagRequest import com.ponzischeme89.memby.data.model.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayAuthError
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
import com.ponzischeme89.memby.data.model.GatewayLoginRequest import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
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.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayUpdate import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport import com.ponzischeme89.memby.data.model.PlaybackReport
@@ -15,12 +18,17 @@ import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi import com.ponzischeme89.memby.data.remote.GatewayApi
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import retrofit2.HttpException import retrofit2.HttpException
@@ -47,6 +55,7 @@ data class Playable(
val title: String, val title: String,
val url: String, val url: String,
val resumePositionMs: Long = 0L, val resumePositionMs: Long = 0L,
val logoUrl: String? = null,
) )
class EmbyRepository(private val settings: SettingsStore) { class EmbyRepository(private val settings: SettingsStore) {
@@ -54,15 +63,20 @@ class EmbyRepository(private val settings: SettingsStore) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile @Volatile
private var snapshot: Settings = Settings.EMPTY private var observedSettings: Settings = Settings.EMPTY
private val snapshot: Settings
get() = settings.current ?: observedSettings
init { init {
scope.launch { settings.settingsFlow.collect { snapshot = it } } scope.launch { settings.settingsFlow.collect { observedSettings = it } }
} }
val settingsFlow: Flow<Settings> get() = settings.settingsFlow val settingsFlow: Flow<Settings> get() = settings.settingsFlow
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1) private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
val playbackStops = _playbackStops.asSharedFlow() val playbackStops = _playbackStops.asSharedFlow()
private val playableMutex = Mutex()
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
fun cachedHome(): HomeCache? = settings.homeCache(snapshot) fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
@@ -119,20 +133,29 @@ class EmbyRepository(private val settings: SettingsStore) {
* Signs in. [serverUrl] is only consulted when the build does not hardwire one — * Signs in. [serverUrl] is only consulted when the build does not hardwire one —
* with a hardwired address the setup screen never collects it. * with a hardwired address the setup screen never collects it.
*/ */
suspend fun authenticate(serverUrl: String, username: String, password: String) { suspend fun authenticate(serverUrl: String, username: String, password: String, deviceName: String) {
clearPlayableCache()
settings.ensureDeviceId() settings.ensureDeviceId()
snapshot = settings.snapshot() // pick up the freshly-generated device id observedSettings = settings.snapshot() // pick up the freshly-generated device id
ServerConfig.gatewayUrl?.let { gateway -> ServerConfig.gatewayUrl?.let { gateway ->
// The gateway holds the Emby token; this device only ever stores the gateway // The gateway holds the Emby token; this device only ever stores the gateway
// token, so the same `token` slot in DataStore serves both modes. // token, so the same `token` slot in DataStore serves both modes.
val result = requireGateway().login( val result = try {
GatewayLoginRequest( requireGateway().login(
username = username, GatewayLoginRequest(
password = password, username = username,
deviceId = snapshot.deviceId.ifEmpty { "memby" }, password = password,
), deviceId = snapshot.deviceId.ifEmpty { "memby" },
) deviceName = deviceName.trim(),
),
)
} catch (error: HttpException) {
if (error.code() == 409) {
parseDeviceLimit(error.response()?.errorBody()?.string().orEmpty())?.let { throw it }
}
throw error
}
require(result.token.isNotBlank() && result.userId.isNotBlank()) { require(result.token.isNotBlank() && result.userId.isNotBlank()) {
"Gateway did not return a session" "Gateway did not return a session"
} }
@@ -143,7 +166,8 @@ class EmbyRepository(private val settings: SettingsStore) {
result.username.ifBlank { username }, result.username.ifBlank { username },
result.serverId.takeIf { it.isNotBlank() }, result.serverId.takeIf { it.isNotBlank() },
) )
snapshot = settings.snapshot() settings.setDeviceName(deviceName)
observedSettings = settings.snapshot()
return return
} }
@@ -157,9 +181,13 @@ class EmbyRepository(private val settings: SettingsStore) {
"Server did not return an access token" "Server did not return an access token"
} }
settings.saveSession(base, token, userId, username, result.serverId) settings.saveSession(base, token, userId, username, result.serverId)
snapshot = settings.snapshot() settings.setDeviceName(deviceName)
observedSettings = settings.snapshot()
} }
suspend fun authPolicy(): GatewayAuthPolicy? =
if (ServerConfig.isGateway) requireGateway().authPolicy() else null
suspend fun signOut() { suspend fun signOut() {
// Retire the gateway token server-side too, so a lost TV can't keep reading the // Retire the gateway token server-side too, so a lost TV can't keep reading the
// library. A failure here must not block the local sign-out. // library. A failure here must not block the local sign-out.
@@ -167,16 +195,18 @@ class EmbyRepository(private val settings: SettingsStore) {
runCatching { requireGateway().logout() } runCatching { requireGateway().logout() }
} }
settings.clearSession() settings.clearSession()
snapshot = settings.snapshot() observedSettings = settings.snapshot()
cachedApi = null cachedApi = null
cachedBaseUrl = null cachedBaseUrl = null
clearPlayableCache()
} }
suspend fun switchProfile(profile: EmbyProfile) { suspend fun switchProfile(profile: EmbyProfile) {
settings.switchProfile(profile) settings.switchProfile(profile)
snapshot = settings.snapshot() observedSettings = settings.snapshot()
cachedApi = null cachedApi = null
cachedBaseUrl = null cachedBaseUrl = null
clearPlayableCache()
} }
// --- Content ------------------------------------------------------------- // --- Content -------------------------------------------------------------
@@ -343,6 +373,9 @@ class EmbyRepository(private val settings: SettingsStore) {
?.takeIf { it.isActionable } ?.takeIf { it.isActionable }
} }
/** Live gateway state. Unlike content routes, this remains available in maintenance. */
suspend fun serviceStatus(): GatewayServiceStatus = requireGateway().serviceStatus()
/** Full item metadata, requested only after focus settles on an item. */ /** Full item metadata, requested only after focus settles on an item. */
suspend fun getItemDetails(itemId: String): BaseItem { suspend fun getItemDetails(itemId: String): BaseItem {
if (ServerConfig.isGateway) return requireGateway().item(itemId) if (ServerConfig.isGateway) return requireGateway().item(itemId)
@@ -445,14 +478,67 @@ class EmbyRepository(private val settings: SettingsStore) {
* for a series we play the next-up episode (falling back to the first one). * for a series we play the next-up episode (falling back to the first one).
*/ */
suspend fun resolvePlayable(item: BaseItem): Playable { suspend fun resolvePlayable(item: BaseItem): Playable {
require(item.membyPlayable) { "This item is informational and cannot be played" }
val now = System.currentTimeMillis()
val (cached, request) = playableMutex.withLock {
val ready = playableCache[item.id]?.takeIf { it.expiresAtMs > now }?.playable
if (ready != null) {
ready to null
} else {
playableCache.remove(item.id)
null to (playableInFlight[item.id] ?: newPlayableRequest(item))
}
}
return cached ?: requireNotNull(request).await()
}
/** Resolves the likely stream after focus settles, without opening or buffering it. */
suspend fun prefetchPlayable(item: BaseItem) {
if (!item.membyPlayable) return
resolvePlayable(item)
}
private fun newPlayableRequest(item: BaseItem): Deferred<Playable> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
resolvePlayableUncached(item).also { playable ->
playableMutex.withLock {
playableCache[item.id] = CachedPlayable(
playable = playable,
expiresAtMs = System.currentTimeMillis() + PLAYABLE_CACHE_TTL_MS,
)
while (playableCache.size > PLAYABLE_CACHE_SIZE) {
playableCache.entries.iterator().run {
next()
remove()
}
}
}
}
} finally {
playableMutex.withLock { playableInFlight.remove(item.id) }
}
}
playableInFlight[item.id] = request
request.start()
return request
}
private suspend fun resolvePlayableUncached(item: BaseItem): Playable {
if (ServerConfig.isGateway) { if (ServerConfig.isGateway) {
// Episode selection for a series is the gateway's job now. // Episode selection for a series is the gateway's job now.
val playback = requireGateway().playback(item.id) val playback = requireGateway().playback(
itemId = item.id,
itemType = item.type,
title = item.name,
resumePositionMs = item.resumePositionMs,
)
return Playable( return Playable(
itemId = playback.itemId, itemId = playback.itemId,
title = playback.title.ifBlank { item.name }, title = playback.title.ifBlank { item.name },
url = playback.url, url = playback.url,
resumePositionMs = playback.resumePositionMs, resumePositionMs = playback.resumePositionMs,
logoUrl = logoUrl(item),
) )
} }
if (item.isSeries) { if (item.isSeries) {
@@ -463,9 +549,29 @@ class EmbyRepository(private val settings: SettingsStore) {
append(item.name) append(item.name)
episode.name.takeIf { it.isNotBlank() }?.let { append(" $it") } episode.name.takeIf { it.isNotBlank() }?.let { append(" $it") }
} }
return Playable(episode.id, title, buildStreamUrl(episode.id), episode.resumePositionMs) return Playable(
episode.id,
title,
buildStreamUrl(episode.id),
episode.resumePositionMs,
logoUrl(item),
)
}
return Playable(
item.id,
item.name,
buildStreamUrl(item.id),
item.resumePositionMs,
logoUrl(item),
)
}
private suspend fun clearPlayableCache() {
playableMutex.withLock {
playableCache.clear()
playableInFlight.values.forEach { it.cancel() }
playableInFlight.clear()
} }
return Playable(item.id, item.name, buildStreamUrl(item.id), item.resumePositionMs)
} }
suspend fun reportPlaybackStarted(itemId: String, positionMs: Long) { suspend fun reportPlaybackStarted(itemId: String, positionMs: Long) {
@@ -494,6 +600,7 @@ class EmbyRepository(private val settings: SettingsStore) {
requireApi().reportPlaybackStopped(playbackReport(itemId, positionMs, isPaused = true)) requireApi().reportPlaybackStopped(playbackReport(itemId, positionMs, isPaused = true))
} }
} finally { } finally {
clearPlayableCache()
_playbackStops.tryEmit(itemId) _playbackStops.tryEmit(itemId)
} }
} }
@@ -628,12 +735,41 @@ class EmbyRepository(private val settings: SettingsStore) {
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8") private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
} }
private data class CachedPlayable(
val playable: Playable,
val expiresAtMs: Long,
)
private const val PLAYABLE_CACHE_SIZE = 16
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
internal fun millisecondsToTicks(milliseconds: Long): Long = internal fun millisecondsToTicks(milliseconds: Long): Long =
milliseconds.coerceAtLeast(0L) * 10_000L milliseconds.coerceAtLeast(0L) * 10_000L
private val BaseItem.resumePositionMs: Long private val BaseItem.resumePositionMs: Long
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L) get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
class DeviceLimitException(
val activeClients: Int,
val maxClients: Int,
) : Exception("Memby device allowance reached")
internal fun parseDeviceLimit(body: String): DeviceLimitException? {
if (body.isBlank()) return null
return runCatching {
val parsed = Json { ignoreUnknownKeys = true }
.decodeFromString<GatewayAuthError>(body)
parsed.takeIf {
it.error == "device_limit_reached" && it.maxClientsPerUser > 0
}?.let {
DeviceLimitException(
activeClients = it.activeClients.coerceAtLeast(0),
maxClients = it.maxClientsPerUser,
)
}
}.getOrNull()
}
/** /**
* Maps an exception to a short, TV-readable message. Never surfaces raw HTTP * Maps an exception to a short, TV-readable message. Never surfaces raw HTTP
* bodies or stack traces (which could contain tokens) to the screen. * bodies or stack traces (which could contain tokens) to the screen.
@@ -10,8 +10,15 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -28,6 +35,7 @@ data class Settings(
val serverId: String? = null, val serverId: String? = null,
val username: String? = null, val username: String? = null,
val deviceId: String = "", val deviceId: String = "",
val deviceName: String = "",
val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS, val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS,
// Where "Check for updates" looks: a Gitea host + "owner/repo", plus an access // Where "Check for updates" looks: a Gitea host + "owner/repo", plus an access
// token for the (private) repo's release API and asset downloads. // token for the (private) repo's release API and asset downloads.
@@ -75,6 +83,12 @@ data class EmbyProfile(
) )
class SettingsStore(private val context: Context) { class SettingsStore(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile
private var latestSettings: Settings? = null
val current: Settings?
get() = latestSettings
private object Keys { private object Keys {
val SERVER_URL = stringPreferencesKey("server_url") val SERVER_URL = stringPreferencesKey("server_url")
@@ -83,6 +97,7 @@ class SettingsStore(private val context: Context) {
val SERVER_ID = stringPreferencesKey("server_id") val SERVER_ID = stringPreferencesKey("server_id")
val USERNAME = stringPreferencesKey("username") val USERNAME = stringPreferencesKey("username")
val DEVICE_ID = stringPreferencesKey("device_id") val DEVICE_ID = stringPreferencesKey("device_id")
val DEVICE_NAME = stringPreferencesKey("device_name")
val ROTATION_SECONDS = intPreferencesKey("rotation_interval_seconds") val ROTATION_SECONDS = intPreferencesKey("rotation_interval_seconds")
val UPDATE_BASE_URL = stringPreferencesKey("update_base_url") val UPDATE_BASE_URL = stringPreferencesKey("update_base_url")
val UPDATE_REPO = stringPreferencesKey("update_repo") val UPDATE_REPO = stringPreferencesKey("update_repo")
@@ -97,39 +112,26 @@ class SettingsStore(private val context: Context) {
val PROFILES = stringPreferencesKey("profiles") val PROFILES = stringPreferencesKey("profiles")
} }
val settingsFlow: Flow<Settings> = context.dataStore.data.map { p -> /**
val storedProfiles = decodeProfiles(p[Keys.PROFILES]) * Start the single disk read as soon as the Application creates this store, then
val profiles = if (storedProfiles.isEmpty()) { * replay the decoded value to activities, repositories and services. Previously
legacyProfile(p)?.let(::listOf).orEmpty() * every collector repeated the cold mapping work and the home screen could be
} else { * created before the repository had seen the saved cache.
storedProfiles */
} val settingsFlow: Flow<Settings> = context.dataStore.data
Settings( .map(::settingsFrom)
serverUrl = p[Keys.SERVER_URL], .distinctUntilChanged()
token = p[Keys.TOKEN], .onEach { latestSettings = it }
userId = p[Keys.USER_ID], .shareIn(scope, started = SharingStarted.Eagerly, replay = 1)
serverId = p[Keys.SERVER_ID],
username = p[Keys.USERNAME],
deviceId = p[Keys.DEVICE_ID].orEmpty(),
rotationIntervalSeconds = p[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS,
updateBaseUrl = p[Keys.UPDATE_BASE_URL],
updateRepo = p[Keys.UPDATE_REPO],
updateToken = p[Keys.UPDATE_TOKEN],
showTitleLogo = p[Keys.SHOW_TITLE_LOGO] ?: true,
ringColorHex = p[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
lastBackdropUrl = p[Keys.LAST_BACKDROP_URL],
homeSections = p[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
homeCacheJson = p[Keys.HOME_CACHE],
homeCardDensity = p[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
showHomeCardMetadata = p[Keys.SHOW_HOME_CARD_METADATA] ?: true,
profiles = profiles,
)
}
suspend fun setRotationIntervalSeconds(seconds: Int) { suspend fun setRotationIntervalSeconds(seconds: Int) {
context.dataStore.edit { it[Keys.ROTATION_SECONDS] = seconds } context.dataStore.edit { it[Keys.ROTATION_SECONDS] = seconds }
} }
suspend fun setDeviceName(name: String) {
context.dataStore.edit { it[Keys.DEVICE_NAME] = name.trim().take(80) }
}
/** Persists the Gitea update source. Blank values are cleared. */ /** Persists the Gitea update source. Blank values are cleared. */
suspend fun setUpdateConfig(baseUrl: String, repo: String, token: String) { suspend fun setUpdateConfig(baseUrl: String, repo: String, token: String) {
context.dataStore.edit { context.dataStore.edit {
@@ -196,8 +198,12 @@ class SettingsStore(private val context: Context) {
runCatching { Json.decodeFromString<HomeCache>(it) }.getOrNull() runCatching { Json.decodeFromString<HomeCache>(it) }.getOrNull()
} }
/** Reads a one-shot snapshot of the current settings. */ /**
suspend fun snapshot(): Settings = settingsFlow.first() * Reads DataStore directly instead of taking the replayed flow value. This matters
* immediately after an edit, when the replay slot may still contain the prior value.
*/
suspend fun snapshot(): Settings = settingsFrom(context.dataStore.data.first())
.also { latestSettings = it }
/** Returns the stable device id, generating and persisting one on first use. */ /** Returns the stable device id, generating and persisting one on first use. */
suspend fun ensureDeviceId(): String { suspend fun ensureDeviceId(): String {
@@ -284,6 +290,36 @@ class SettingsStore(private val context: Context) {
private fun decodeProfiles(value: String?): List<EmbyProfile> = private fun decodeProfiles(value: String?): List<EmbyProfile> =
value?.let { runCatching { Json.decodeFromString<List<EmbyProfile>>(it) }.getOrNull() }.orEmpty() value?.let { runCatching { Json.decodeFromString<List<EmbyProfile>>(it) }.getOrNull() }.orEmpty()
private fun settingsFrom(preferences: Preferences): Settings {
val storedProfiles = decodeProfiles(preferences[Keys.PROFILES])
val profiles = if (storedProfiles.isEmpty()) {
legacyProfile(preferences)?.let(::listOf).orEmpty()
} else {
storedProfiles
}
return Settings(
serverUrl = preferences[Keys.SERVER_URL],
token = preferences[Keys.TOKEN],
userId = preferences[Keys.USER_ID],
serverId = preferences[Keys.SERVER_ID],
username = preferences[Keys.USERNAME],
deviceId = preferences[Keys.DEVICE_ID].orEmpty(),
deviceName = preferences[Keys.DEVICE_NAME].orEmpty(),
rotationIntervalSeconds = preferences[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS,
updateBaseUrl = preferences[Keys.UPDATE_BASE_URL],
updateRepo = preferences[Keys.UPDATE_REPO],
updateToken = preferences[Keys.UPDATE_TOKEN],
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
ringColorHex = preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
homeCacheJson = preferences[Keys.HOME_CACHE],
homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
profiles = profiles,
)
}
private fun profilesFrom(preferences: Preferences): List<EmbyProfile> = private fun profilesFrom(preferences: Preferences): List<EmbyProfile> =
decodeProfiles(preferences[Keys.PROFILES]).ifEmpty { decodeProfiles(preferences[Keys.PROFILES]).ifEmpty {
legacyProfile(preferences)?.let(::listOf).orEmpty() legacyProfile(preferences)?.let(::listOf).orEmpty()
@@ -86,11 +86,22 @@ data class BaseItem(
@SerialName("ParentLogoItemId") val parentLogoItemId: String? = null, @SerialName("ParentLogoItemId") val parentLogoItemId: String? = null,
@SerialName("ParentLogoImageTag") val parentLogoImageTag: String? = null, @SerialName("ParentLogoImageTag") val parentLogoImageTag: String? = null,
@SerialName("UserData") val userData: UserItemData? = null, @SerialName("UserData") val userData: UserItemData? = null,
// Server-authored schedule metadata. These fields are absent on normal Emby items.
@SerialName("MembySource") val membySource: String? = null,
@SerialName("MembyEpisodeTitle") val membyEpisodeTitle: String? = null,
@SerialName("MembyEpisodeCode") val membyEpisodeCode: String? = null,
@SerialName("MembyAirsAt") val membyAirsAt: String? = null,
@SerialName("MembyAddedAt") val membyAddedAt: String? = null,
@SerialName("MembyAirLabel") val membyAirLabel: String? = null,
@SerialName("MembyAvailability") val membyAvailability: String? = null,
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
) { ) {
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true) val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true) val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true) val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true)
val isFavorite: Boolean get() = userData?.isFavorite == true val isFavorite: Boolean get() = userData?.isFavorite == true
val isSonarrSchedule: Boolean get() = membySource == "sonarr"
/** Runtime in whole minutes, or null when unknown. */ /** Runtime in whole minutes, or null when unknown. */
val runtimeMinutes: Int? val runtimeMinutes: Int?
@@ -15,6 +15,7 @@ data class GatewayLoginRequest(
val username: String, val username: String,
val password: String, val password: String,
val deviceId: String, val deviceId: String,
val deviceName: String,
) )
@Serializable @Serializable
@@ -23,6 +24,21 @@ data class GatewayLoginResponse(
val userId: String = "", val userId: String = "",
val username: String = "", val username: String = "",
val serverId: String = "", val serverId: String = "",
val activeClients: Int = 0,
val maxClientsPerUser: Int = 0,
)
@Serializable
data class GatewayAuthPolicy(
val maxClientsPerUser: Int = 0,
)
@Serializable
data class GatewayAuthError(
val error: String = "",
val message: String = "",
val activeClients: Int = 0,
val maxClientsPerUser: Int = 0,
) )
/** /**
@@ -83,6 +99,13 @@ data class GatewayUpdate(
} }
} }
/** Lightweight live state returned even while normal gateway routes are in maintenance. */
@Serializable
data class GatewayServiceStatus(
val maintenance: Boolean = false,
val message: String = "",
)
/** Response of `GET /v1/recommendations`. */ /** Response of `GET /v1/recommendations`. */
@Serializable @Serializable
data class GatewayRows( data class GatewayRows(
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.data.remote
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayFlagRequest import com.ponzischeme89.memby.data.model.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
import com.ponzischeme89.memby.data.model.GatewayHome import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayItems import com.ponzischeme89.memby.data.model.GatewayItems
import com.ponzischeme89.memby.data.model.GatewayLoginRequest import com.ponzischeme89.memby.data.model.GatewayLoginRequest
@@ -10,6 +11,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.GatewayRowEvents import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayRows import com.ponzischeme89.memby.data.model.GatewayRows
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayUpdate import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.UserItemData import com.ponzischeme89.memby.data.model.UserItemData
import retrofit2.http.Body import retrofit2.http.Body
@@ -30,6 +32,9 @@ interface GatewayApi {
@POST("v1/auth/login") @POST("v1/auth/login")
suspend fun login(@Body body: GatewayLoginRequest): GatewayLoginResponse suspend fun login(@Body body: GatewayLoginRequest): GatewayLoginResponse
@GET("v1/auth/policy")
suspend fun authPolicy(): GatewayAuthPolicy
@POST("v1/auth/logout") @POST("v1/auth/logout")
suspend fun logout() suspend fun logout()
@@ -53,11 +58,20 @@ interface GatewayApi {
@GET("v1/update") @GET("v1/update")
suspend fun updateStatus(): GatewayUpdate suspend fun updateStatus(): GatewayUpdate
/** Available during maintenance so an open app can be interrupted immediately. */
@GET("v1/status")
suspend fun serviceStatus(): GatewayServiceStatus
@GET("v1/items/{id}") @GET("v1/items/{id}")
suspend fun item(@Path("id") itemId: String): BaseItem suspend fun item(@Path("id") itemId: String): BaseItem
@GET("v1/items/{id}/playback") @GET("v1/items/{id}/playback")
suspend fun playback(@Path("id") itemId: String): GatewayPlayback suspend fun playback(
@Path("id") itemId: String,
@Query("type") itemType: String,
@Query("title") title: String,
@Query("resumePositionMs") resumePositionMs: Long,
): GatewayPlayback
@GET("v1/items/{id}/trailer") @GET("v1/items/{id}/trailer")
suspend fun trailer(@Path("id") itemId: String): BaseItem suspend fun trailer(@Path("id") itemId: String): BaseItem
@@ -69,16 +69,25 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.material.icons.filled.Recommend
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.filled.Tv import androidx.compose.material.icons.filled.Tv
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.Text import androidx.tv.material3.Text
@@ -120,6 +129,58 @@ data class HomeBrowseRow(
val showSecondaryMetadata: Boolean = true, val showSecondaryMetadata: Boolean = true,
) )
private data class HomeRowVisual(
val icon: ImageVector,
val colors: List<Color>,
)
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
row.id == "continue" -> HomeRowVisual(
Icons.Default.PlayCircleFilled,
listOf(Color(0xFF3A7BD5), Color(0xFF6C4DFF)),
)
row.id == "next-up" -> HomeRowVisual(
Icons.Default.SkipNext,
listOf(Color(0xFF00A896), Color(0xFF28C2A0)),
)
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
Icons.Default.Favorite,
listOf(Color(0xFFFF5C8A), Color(0xFFB84DFF)),
)
row.id == "latest-movies" -> HomeRowVisual(
Icons.Default.Movie,
listOf(Color(0xFFFF8A3D), Color(0xFFE34B6F)),
)
row.id == "sonarr-airing-today" -> HomeRowVisual(
Icons.Default.CalendarMonth,
listOf(Color(0xFF00A9CE), Color(0xFF4169E1)),
)
row.id == "curated:apple-tv" -> HomeRowVisual(
Icons.Default.LiveTv,
listOf(Color(0xFF252A31), Color(0xFF66717E)),
)
row.id == "curated:drama-shows" -> HomeRowVisual(
Icons.Default.TheaterComedy,
listOf(Color(0xFF6A3FB5), Color(0xFFB64272)),
)
row.id == "curated:comedy-shows" -> HomeRowVisual(
Icons.Default.SentimentVerySatisfied,
listOf(Color(0xFFFFB52E), Color(0xFFFF7433)),
)
row.id.startsWith("similar:") -> HomeRowVisual(
Icons.Default.AutoAwesome,
listOf(Color(0xFF8A5CF6), Color(0xFFE458A3)),
)
row.id == "recommended" -> HomeRowVisual(
Icons.Default.Recommend,
listOf(Color(0xFF45B649), Color(0xFF00A896)),
)
else -> HomeRowVisual(
Icons.Default.VideoLibrary,
listOf(Color(0xFF536976), Color(0xFF738A96)),
)
}
@Composable @Composable
fun TvNavigationRail( fun TvNavigationRail(
selected: BrowseDestination, selected: BrowseDestination,
@@ -160,11 +221,27 @@ fun TvNavigationRail(
) { ) {
Image( Image(
painter = painterResource(R.drawable.emby_logo), painter = painterResource(R.drawable.emby_logo),
contentDescription = "Emby", contentDescription = "Memby",
modifier = Modifier.width(32.dp).height(27.dp), modifier = Modifier.width(32.dp).height(27.dp),
) )
if (expanded) { if (expanded) {
Text("Emby", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold) Column(verticalArrangement = Arrangement.Center) {
Text(
"Memby",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
Text(
"Matts Android TV client",
color = QuietText,
fontSize = 9.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -528,6 +605,10 @@ fun MediaQuickActionsOverlay(
@Composable @Composable
private fun MetadataContent(item: BaseItem, sectionLabel: String) { private fun MetadataContent(item: BaseItem, sectionLabel: String) {
if (item.isSonarrSchedule) {
ScheduleMetadataContent(item, sectionLabel)
return
}
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(7.dp), verticalArrangement = Arrangement.spacedBy(7.dp),
@@ -574,6 +655,62 @@ private fun MetadataContent(item: BaseItem, sectionLabel: String) {
} }
} }
@Composable
private fun ScheduleMetadataContent(item: BaseItem, sectionLabel: String) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(7.dp),
) {
Text(
sectionLabel.uppercase(),
color = EmbyGreen,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
)
Text(
item.name,
color = Color.White,
fontSize = 30.sp,
lineHeight = 34.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
listOfNotNull(
item.membyEpisodeCode,
item.membyEpisodeTitle?.takeIf(String::isNotBlank),
).joinToString(""),
color = MutedText,
fontSize = 16.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item.membyAirLabel?.takeIf(String::isNotBlank)?.let { MediaBadge(it.uppercase()) }
item.membyAvailabilityText?.takeIf(String::isNotBlank)?.let {
MediaBadge(it.uppercase())
}
}
Text(
item.overview?.takeIf(String::isNotBlank)
?: "Episode information will appear when Sonarr receives it.",
color = Color(0xFFD0D4D7),
fontSize = 15.sp,
lineHeight = 20.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(0.74f),
)
Text(
"Schedule information from Sonarr",
color = QuietText,
fontSize = 12.sp,
)
}
}
@Composable @Composable
private fun MetadataStatus(item: BaseItem) { private fun MetadataStatus(item: BaseItem) {
val position = item.userData?.playbackPositionTicks ?: 0L val position = item.userData?.playbackPositionTicks ?: 0L
@@ -656,6 +793,23 @@ fun MediaRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val visual = homeRowVisual(row)
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(Brush.linearGradient(visual.colors))
.border(1.dp, Color.White.copy(alpha = 0.20f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = visual.icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(15.dp),
)
}
Spacer(Modifier.width(10.dp))
Text( Text(
row.title, row.title,
color = Color(0xFFF1F3F4), color = Color(0xFFF1F3F4),
@@ -978,6 +1132,12 @@ private fun MediaCard(
} }
} }
} }
if (item.isSonarrSchedule) {
ScheduleStatusBadge(
status = item.membyAvailability.orEmpty(),
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
)
}
} }
Text( Text(
item.name, item.name,
@@ -1004,6 +1164,28 @@ private fun MediaCard(
} }
} }
@Composable
private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) {
val (label, color) = when (status) {
"available" -> "ADDED" to EmbyGreen
"downloading" -> "DOWNLOADING" to Color(0xFF5DA9FF)
"awaiting" -> "AWAITING" to Color(0xFFFFB454)
"unmonitored" -> "UNMONITORED" to QuietText
else -> "TODAY" to Color(0xFFE1E5E8)
}
Text(
text = label,
color = if (status == "available") Color(0xFF071008) else Color(0xFF090B0D),
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
modifier = modifier
.clip(RoundedCornerShape(4.dp))
.background(color)
.padding(horizontal = 7.dp, vertical = 4.dp),
)
}
@Composable @Composable
fun FocusScaleContainer( fun FocusScaleContainer(
onFocused: () -> Unit, onFocused: () -> Unit,
@@ -1127,6 +1309,7 @@ private const val BACKDROP_SETTLE_DELAY_MS = 240L
private fun cardSubtitle(item: BaseItem, showProgress: Boolean, positionTicks: Long): String = when { private fun cardSubtitle(item: BaseItem, showProgress: Boolean, positionTicks: Long): String = when {
showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}" showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}"
item.isSonarrSchedule -> item.membyAirLabel ?: "Airing today"
item.isEpisode -> item.seriesName ?: "Up next" item.isEpisode -> item.seriesName ?: "Up next"
item.productionYear != null && item.runtimeMinutes != null -> item.productionYear != null && item.runtimeMinutes != null ->
"${item.productionYear}${formatTvRuntime(requireNotNull(item.runtimeMinutes))}" "${item.productionYear}${formatTvRuntime(requireNotNull(item.runtimeMinutes))}"
@@ -1141,6 +1324,10 @@ private fun cardDescription(item: BaseItem, progress: Float): String = buildStri
if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched") if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched")
if (item.userData?.played == true) append(", watched") if (item.userData?.played == true) append(", watched")
if (item.isFavorite) append(", favourite") if (item.isFavorite) append(", favourite")
if (item.isSonarrSchedule) {
item.membyAirLabel?.let { append(", ").append(it) }
item.membyAvailabilityText?.let { append(", ").append(it) }
}
} }
private fun formatTvRuntime(minutes: Int): String { private fun formatTvRuntime(minutes: Int): String {
@@ -103,6 +103,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
init { init {
refreshAll() refreshAll()
checkForAppUpdate() checkForAppUpdate()
viewModelScope.launch {
// A TV can leave Memby open for days. Keep checking at a deliberately slow
// cadence so a newly published release appears without requiring a restart.
while (true) {
delay(UPDATE_CHECK_INTERVAL_MS)
checkForAppUpdate()
}
}
viewModelScope.launch { viewModelScope.launch {
repository.playbackStops.collect { refreshWatching() } repository.playbackStops.collect { refreshWatching() }
} }
@@ -224,13 +232,26 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
val cached = synchronized(metadataCache) { metadataCache[item.id] } val cached = synchronized(metadataCache) { metadataCache[item.id] }
_focusedItem.value = cached ?: item _focusedItem.value = cached ?: item
metadataJob?.cancel() metadataJob?.cancel()
if (cached != null) return
metadataJob = viewModelScope.launch(Dispatchers.IO) { metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS) delay(FOCUS_METADATA_DEBOUNCE_MS)
val details = runCatching { repository.getItemDetails(item.id) }.getOrNull() ?: return@launch coroutineScope {
synchronized(metadataCache) { metadataCache[item.id] = details } // Resolution is tiny compared with video buffering and makes the later
if (_focusedItem.value?.id == item.id) { // Play click a memory lookup. The repository single-flights requests,
_focusedItem.value = details // so focus and click can never duplicate the gateway call.
if (item.membyPlayable) {
launch { runCatching { repository.prefetchPlayable(cached ?: item) } }
}
if (cached == null && !item.isSonarrSchedule) {
launch {
val details = runCatching {
repository.getItemDetails(item.id)
}.getOrNull() ?: return@launch
synchronized(metadataCache) { metadataCache[item.id] = details }
if (_focusedItem.value?.id == item.id) {
_focusedItem.value = details
}
}
}
} }
} }
} }
@@ -381,6 +402,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
companion object { companion object {
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L
private fun initialFocusedItem(state: HomeUiState): BaseItem? = private fun initialFocusedItem(state: HomeUiState): BaseItem? =
state.watchingAndNextUp.firstOrNull() state.watchingAndNextUp.firstOrNull()
@@ -1,8 +1,12 @@
package com.ponzischeme89.memby.ui package com.ponzischeme89.memby.ui
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.SystemClock
import android.provider.Settings as AndroidSettings import android.provider.Settings as AndroidSettings
import android.text.format.DateFormat
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -11,6 +15,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -45,6 +50,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
@@ -71,6 +77,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
@@ -89,7 +96,7 @@ import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.EmbyProfile import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.ServerConfig import com.ponzischeme89.memby.data.DeviceLimitException
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.player.PlayerActivity import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.performance.PerformanceMonitor import com.ponzischeme89.memby.performance.PerformanceMonitor
@@ -102,6 +109,7 @@ import androidx.tv.material3.Button
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Date
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -126,6 +134,8 @@ class MainActivity : ComponentActivity() {
private fun AppRoot(onCloseSettings: () -> Unit) { private fun AppRoot(onCloseSettings: () -> Unit) {
val repo = ServiceLocator.repository val repo = ServiceLocator.repository
var settings by remember { mutableStateOf<Settings?>(null) } var settings by remember { mutableStateOf<Settings?>(null) }
var addingProfile by rememberSaveable { mutableStateOf(false) }
var startingFirstRun by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(repo) { LaunchedEffect(repo) {
repo.settingsFlow.collect { settings = it } repo.settingsFlow.collect { settings = it }
} }
@@ -134,155 +144,473 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
val loaded = settings val loaded = settings
when { when {
loaded == null -> MembyLoadingScreen() loaded == null -> MembyLoadingScreen()
addingProfile -> SetupScreen(
onCancel = { addingProfile = false },
onSignedIn = { addingProfile = false },
)
loaded.isSignedIn -> { loaded.isSignedIn -> {
BackHandler(onBack = onCloseSettings) BackHandler(onBack = onCloseSettings)
key(loaded.userId, loaded.serverUrl) { key(loaded.userId, loaded.serverUrl) {
HomeScreen(loaded) HomeScreen(
settings = loaded,
onAddProfile = { addingProfile = true },
)
} }
} }
loaded.profiles.isNotEmpty() -> ProfileEntryScreen(loaded) loaded.profiles.isNotEmpty() -> ProfileEntryScreen(
else -> SetupScreen() settings = loaded,
onAddProfile = { addingProfile = true },
)
startingFirstRun -> SetupScreen(
onCancel = { startingFirstRun = false },
onSignedIn = { startingFirstRun = false },
)
else -> FirstRunScreen(onGetStarted = { startingFirstRun = true })
} }
} }
} }
@Composable @Composable
private fun MembyLoadingScreen() { private fun MembyLoadingScreen() {
Column( val transition = rememberInfiniteTransition(label = "cold-start")
modifier = Modifier.fillMaxSize(), val pulse by transition.animateFloat(
verticalArrangement = Arrangement.Center, initialValue = 0.96f,
horizontalAlignment = Alignment.CenterHorizontally, targetValue = 1.04f,
animationSpec = infiniteRepeatable(
animation = tween(750),
repeatMode = RepeatMode.Reverse,
),
label = "cold-start-logo-pulse",
)
val glow by transition.animateFloat(
initialValue = 0.35f,
targetValue = 0.78f,
animationSpec = infiniteRepeatable(
animation = tween(750),
repeatMode = RepeatMode.Reverse,
),
label = "cold-start-glow",
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(Color(0xFF16231E), Color(0xFF090C0F)),
radius = 1_100f,
),
),
contentAlignment = Alignment.Center,
) { ) {
androidx.compose.foundation.Image( Column(
painter = androidx.compose.ui.res.painterResource(com.ponzischeme89.memby.R.drawable.emby_logo), verticalArrangement = Arrangement.spacedBy(14.dp),
contentDescription = "Emby", horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(92.dp).height(76.dp), ) {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby",
modifier = Modifier
.width(82.dp)
.height(68.dp)
.graphicsLayer {
scaleX = pulse
scaleY = pulse
alpha = 0.82f + (glow * 0.18f)
},
)
Text(
"Opening Memby…",
color = Color.White.copy(alpha = 0.88f),
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
)
Box(
Modifier
.width(92.dp)
.height(2.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.10f)),
) {
Box(
Modifier
.fillMaxWidth(glow)
.height(2.dp)
.clip(CircleShape)
.background(Color(0xFF52B54B)),
)
}
}
}
}
@Composable
private fun FirstRunScreen(onGetStarted: () -> Unit) {
val getStartedFocus = remember { FocusRequester() }
var entered by remember { mutableStateOf(false) }
val contentAlpha by animateFloatAsState(
targetValue = if (entered) 1f else 0f,
animationSpec = tween(420),
label = "first-run-content-alpha",
)
val contentOffset by animateDpAsState(
targetValue = if (entered) 0.dp else 18.dp,
animationSpec = tween(420),
label = "first-run-content-offset",
)
val ambient = rememberInfiniteTransition(label = "first-run-ambient")
val logoScale by ambient.animateFloat(
initialValue = 0.97f,
targetValue = 1.04f,
animationSpec = infiniteRepeatable(
animation = tween(1_800),
repeatMode = RepeatMode.Reverse,
),
label = "first-run-logo-scale",
)
val haloAlpha by ambient.animateFloat(
initialValue = 0.10f,
targetValue = 0.22f,
animationSpec = infiniteRepeatable(
animation = tween(1_800),
repeatMode = RepeatMode.Reverse,
),
label = "first-run-halo",
)
LaunchedEffect(Unit) {
entered = true
kotlinx.coroutines.delay(180L)
runCatching { getStartedFocus.requestFocus() }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = listOf(
Color(0xFF0A0E11),
Color(0xFF101A17),
Color(0xFF090B0D),
),
),
),
) {
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.offset(x = 70.dp)
.size(560.dp)
.graphicsLayer { alpha = haloAlpha }
.background(
Brush.radialGradient(
colors = listOf(Color(0xFF52B54B), Color.Transparent),
),
CircleShape,
),
) )
Spacer(Modifier.height(22.dp))
Text("Emby is loading", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold) Row(
Spacer(Modifier.height(10.dp)) modifier = Modifier
Text("Preparing your library…", color = Color(0xFF9EA6AD), fontSize = 16.sp) .fillMaxSize()
.padding(horizontal = 88.dp, vertical = 64.dp)
.graphicsLayer { alpha = contentAlpha }
.offset(y = contentOffset),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(
modifier = Modifier.width(590.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text(
"WELCOME TO MEMBY",
color = Color(0xFF69CD61),
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 2.sp,
)
Text(
"Whos watching?",
color = Color.White,
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
)
Text(
"Sign in to bring your Emby library, progress and favourites together on this TV.",
color = Color(0xFFB8C0C6),
fontSize = 20.sp,
lineHeight = 29.sp,
)
Text(
"You can add the rest of the household afterwards.",
color = Color(0xFF818C94),
fontSize = 16.sp,
)
Spacer(Modifier.height(8.dp))
Button(
onClick = onGetStarted,
modifier = Modifier.focusRequester(getStartedFocus),
) {
Text("Get started")
}
}
Box(
modifier = Modifier.size(330.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(250.dp)
.graphicsLayer {
scaleX = logoScale
scaleY = logoScale
alpha = haloAlpha
}
.background(Color(0xFF52B54B), CircleShape),
)
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = null,
modifier = Modifier
.width(178.dp)
.height(148.dp)
.graphicsLayer {
scaleX = logoScale
scaleY = logoScale
},
)
}
}
} }
} }
@Composable @Composable
private fun SetupScreen( private fun SetupScreen(
onCancel: (() -> Unit)? = null, onCancel: (() -> Unit)? = null,
onSignedIn: () -> Unit = {},
) { ) {
val repo = ServiceLocator.repository val repo = ServiceLocator.repository
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// A build that hardwires the server (memby.serverUrl) never asks for an address. val suggestedName = remember(context) { suggestedDeviceName(context) }
val hardwiredHost = ServerConfig.displayHost var deviceName by rememberSaveable {
var serverUrl by rememberSaveable { mutableStateOf(if (hardwiredHost != null) "" else "http://") } mutableStateOf(
ServiceLocator.settings.current?.deviceName
?.takeIf { it.isNotBlank() }
?: suggestedName,
)
}
var username by rememberSaveable { mutableStateOf("") } var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") }
var connecting by remember { mutableStateOf(false) } var connecting by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var maxClientsPerUser by remember { mutableStateOf<Int?>(null) }
val serverFocus = remember { FocusRequester() } val deviceNameFocus = remember { FocusRequester() }
val usernameFocus = remember { FocusRequester() } LaunchedEffect(repo) {
LaunchedEffect(hardwiredHost) { maxClientsPerUser = runCatching { repo.authPolicy()?.maxClientsPerUser }
runCatching { if (hardwiredHost != null) usernameFocus.requestFocus() else serverFocus.requestFocus() } .getOrNull()
?.takeIf { it > 0 }
}
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(100L)
runCatching { deviceNameFocus.requestFocus() }
} }
if (onCancel != null) BackHandler(onBack = onCancel) if (onCancel != null) BackHandler(onBack = onCancel)
Column( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 96.dp, vertical = 56.dp) .background(
.width(720.dp), Brush.radialGradient(
verticalArrangement = Arrangement.spacedBy(20.dp), colors = listOf(Color(0xFF172126), Color(0xFF090C0F)),
) { radius = 1_250f,
Text("Connect to Memby", color = Color.White, fontSize = 40.sp, fontWeight = FontWeight.Bold) ),
Text(
if (hardwiredHost != null) {
"Sign in with your Emby username and password."
} else {
"Enter your Emby server address and sign in."
},
color = Color(0xFFB9C0C7),
fontSize = 18.sp,
)
if (hardwiredHost != null) {
Text("Server: $hardwiredHost", color = Color(0xFF9AA3AC), fontSize = 15.sp)
} else {
TvTextField(
label = "Server address (e.g. http://192.168.1.10:8096)",
value = serverUrl,
onValueChange = { serverUrl = it; error = null },
modifier = Modifier.focusRequester(serverFocus),
keyboardType = KeyboardType.Uri,
) )
} .padding(horizontal = 72.dp, vertical = 44.dp),
TvTextField( contentAlignment = Alignment.Center,
label = "Username", ) {
value = username, Column(
onValueChange = { username = it; error = null }, modifier = Modifier
modifier = Modifier.focusRequester(usernameFocus), .width(620.dp)
) .clip(RoundedCornerShape(22.dp))
TvTextField( .background(Color(0xF2161C20))
label = "Password", .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(22.dp))
value = password, .padding(horizontal = 44.dp, vertical = 38.dp),
onValueChange = { password = it; error = null }, verticalArrangement = Arrangement.spacedBy(18.dp),
isPassword = true,
)
error?.let { Text(it, color = Color(0xFFFF6B6B), fontSize = 16.sp) }
Button(
onClick = {
if (hardwiredHost == null && serverUrl.isBlank()) {
error = "Server address is required."
return@Button
}
if (username.isBlank()) {
error = "Username is required."
return@Button
}
connecting = true
error = null
scope.launch {
runCatching { repo.authenticate(serverUrl, username, password) }
.onFailure { error = "Sign-in failed: ${it.message}" }
connecting = false
}
},
) { ) {
Text(if (connecting) "Connecting…" else "Connect") Row(
} verticalAlignment = Alignment.CenterVertically,
if (onCancel != null) { horizontalArrangement = Arrangement.spacedBy(16.dp),
Button(onClick = onCancel) { Text("Cancel") } ) {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby",
modifier = Modifier.size(48.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
if (onCancel != null) "Add another viewer" else "Welcome to Memby",
color = Color.White,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
)
Text(
"Sign in with an Emby account",
color = Color(0xFF9EA9B1),
fontSize = 16.sp,
)
}
}
Text(
text = if (onCancel != null) {
"Their account will be saved as another profile on this TV."
} else {
"Enter your username and password to start watching."
},
color = Color(0xFFBAC2C8),
fontSize = 17.sp,
)
TvTextField(
label = "Name this TV",
value = deviceName,
onValueChange = { deviceName = it.take(80); error = null },
modifier = Modifier.focusRequester(deviceNameFocus),
)
Text(
"We suggested this from your TV. A room name such as “Dads TV” makes it easier to recognise later.",
color = Color(0xFF89959D),
fontSize = 14.sp,
)
TvTextField(
label = "Username",
value = username,
onValueChange = { username = it; error = null },
modifier = Modifier,
)
TvTextField(
label = "Password",
value = password,
onValueChange = { password = it; error = null },
isPassword = true,
)
maxClientsPerUser?.let { maximum ->
Text(
"Each Emby account can be signed in on up to $maximum Memby devices.",
color = Color(0xFF76C970),
fontSize = 14.sp,
)
}
error?.let { Text(it, color = Color(0xFFFF7777), fontSize = 16.sp) }
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Button(
onClick = {
if (deviceName.isBlank()) {
error = "Give this TV a name."
return@Button
}
if (username.isBlank()) {
error = "Username is required."
return@Button
}
connecting = true
error = null
scope.launch {
runCatching {
repo.authenticate(
serverUrl = "",
username = username.trim(),
password = password,
deviceName = deviceName.trim(),
)
}
.onSuccess { onSignedIn() }
.onFailure { failure ->
error = when (failure) {
is DeviceLimitException ->
"Device limit reached: this account is using " +
"${failure.activeClients} of ${failure.maxClients} devices. " +
"Sign out on another TV, then try again."
else ->
"Memby couldn't sign in. Check the username and password and try again."
}
}
connecting = false
}
},
enabled = !connecting,
) {
Text(if (connecting) "Signing in…" else "Sign in")
}
if (onCancel != null) {
Button(
onClick = onCancel,
enabled = !connecting,
) {
Text("Back")
}
}
}
} }
} }
} }
internal fun suggestedDeviceName(context: android.content.Context): String {
val systemName = runCatching {
AndroidSettings.Global.getString(context.contentResolver, "device_name")
}.getOrNull()?.trim().orEmpty()
if (systemName.isNotBlank()) return systemName.take(80)
val manufacturer = Build.MANUFACTURER
?.trim()
.orEmpty()
.takeUnless { it.equals("unknown", ignoreCase = true) }
.orEmpty()
val model = Build.MODEL
?.trim()
.orEmpty()
.takeUnless { it.equals("unknown", ignoreCase = true) }
.orEmpty()
val inferred = listOf(manufacturer, model)
.filter { it.isNotBlank() }
.distinctBy { it.lowercase() }
.joinToString(" ")
return inferred.ifBlank { "Living room TV" }.take(80)
}
@Composable @Composable
private fun ProfileEntryScreen(settings: Settings) { private fun ProfileEntryScreen(
settings: Settings,
onAddProfile: () -> Unit,
) {
val repo = ServiceLocator.repository val repo = ServiceLocator.repository
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var addingProfile by remember { mutableStateOf(false) }
var switchingProfileId by remember { mutableStateOf<String?>(null) } var switchingProfileId by remember { mutableStateOf<String?>(null) }
if (addingProfile) { ProfileChooser(
SetupScreen(onCancel = { addingProfile = false }) profiles = settings.profiles,
} else { currentProfileId = null,
ProfileChooser( switchingProfileId = switchingProfileId,
profiles = settings.profiles, onSelect = { profile ->
currentProfileId = null, switchingProfileId = profile.id
switchingProfileId = switchingProfileId, scope.launch {
onSelect = { profile -> runCatching { repo.switchProfile(profile) }
switchingProfileId = profile.id .onFailure { switchingProfileId = null }
scope.launch { }
runCatching { repo.switchProfile(profile) } },
.onFailure { switchingProfileId = null } onAddProfile = onAddProfile,
} onClose = null,
}, )
onAddProfile = { addingProfile = true },
onClose = null,
)
}
} }
@Composable @Composable
@@ -311,11 +639,11 @@ private fun ProfileChooser(
) { ) {
androidx.compose.foundation.Image( androidx.compose.foundation.Image(
painter = androidx.compose.ui.res.painterResource(com.ponzischeme89.memby.R.drawable.emby_logo), painter = androidx.compose.ui.res.painterResource(com.ponzischeme89.memby.R.drawable.emby_logo),
contentDescription = "Emby", contentDescription = "Memby",
modifier = Modifier.width(72.dp).height(60.dp), modifier = Modifier.width(72.dp).height(60.dp),
) )
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Text("Whos watching Emby?", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.SemiBold) Text("Whos watching?", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.SemiBold)
Text( Text(
if (switchingProfileId == null) "Choose a profile to continue" else "Switching profile…", if (switchingProfileId == null) "Choose a profile to continue" else "Switching profile…",
color = Color(0xFFAAB1B7), color = Color(0xFFAAB1B7),
@@ -336,7 +664,7 @@ private fun ProfileChooser(
) )
} }
ProfileTile( ProfileTile(
name = "Add someone else", name = "Add another user",
current = false, current = false,
enabled = switchingProfileId == null, enabled = switchingProfileId == null,
symbol = "+", symbol = "+",
@@ -346,7 +674,7 @@ private fun ProfileChooser(
} }
if (onClose != null) { if (onClose != null) {
Spacer(Modifier.height(30.dp)) Spacer(Modifier.height(30.dp))
Button(onClick = onClose, enabled = switchingProfileId == null) { Text("Back to Emby") } Button(onClick = onClose, enabled = switchingProfileId == null) { Text("Back to Memby") }
} }
} }
} }
@@ -398,40 +726,71 @@ private fun ProfileTile(
} }
@Composable @Composable
private fun HomeScreen(settings: Settings) { private fun HomeScreen(
settings: Settings,
onAddProfile: () -> Unit,
) {
val repo = ServiceLocator.repository val repo = ServiceLocator.repository
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val factory = remember(repo) { HomeViewModelFactory(repo) } val factory = remember(repo) { HomeViewModelFactory(repo) }
val homeViewModel: HomeViewModel = viewModel(factory = factory) val homeViewModel: HomeViewModel = viewModel(factory = factory)
val homeState by homeViewModel.state.collectAsStateWithLifecycle() val homeState by homeViewModel.state.collectAsStateWithLifecycle()
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
var showSettings by remember { mutableStateOf(false) } var showSettings by remember { mutableStateOf(false) }
var showProfiles by remember { mutableStateOf(false) } var showProfiles by remember { mutableStateOf(false) }
var addProfile by remember { mutableStateOf(false) }
var switchingProfileId by remember { mutableStateOf<String?>(null) } var switchingProfileId by remember { mutableStateOf<String?>(null) }
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
var navigationExpanded by remember { mutableStateOf(false) } var navigationExpanded by remember { mutableStateOf(false) }
var detailsItem by remember { mutableStateOf<BaseItem?>(null) } var detailsItem by remember { mutableStateOf<BaseItem?>(null) }
var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) } var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) }
var launchingItem by remember { mutableStateOf<BaseItem?>(null) }
var returnRowId by rememberSaveable { mutableStateOf<String?>(null) } var returnRowId by rememberSaveable { mutableStateOf<String?>(null) }
var returnItemId by rememberSaveable { mutableStateOf<String?>(null) } var returnItemId by rememberSaveable { mutableStateOf<String?>(null) }
val navigationFocusRequester = remember { FocusRequester() } val navigationFocusRequester = remember { FocusRequester() }
val contentFocusRequester = remember { FocusRequester() } val contentFocusRequester = remember { FocusRequester() }
val cardReturnFocusRequester = remember { FocusRequester() } val cardReturnFocusRequester = remember { FocusRequester() }
var initialFocusRequested by remember { mutableStateOf(false) } var initialFocusRequested by remember { mutableStateOf(false) }
val playItem: (BaseItem) -> Unit = { item ->
LaunchedEffect(liveMaintenance) {
if (liveMaintenance != null) {
// Maintenance is an app-wide interruption, not another overlay in the stack.
showSettings = false
showProfiles = false
detailsItem = null
quickMenuItem = null
} else if (homeState.maintenanceMessage != null) {
// A successful live status is authoritative; refresh content immediately
// instead of waiting for the maintenance screen's slower retry timer.
homeViewModel.refreshAll()
}
}
val playItem: (BaseItem) -> Unit = playItem@{ item ->
if (launchingItem != null || !item.membyPlayable) return@playItem
launchingItem = item
val playbackRequestedAtMs = SystemClock.elapsedRealtime()
scope.launch { scope.launch {
runCatching { repo.resolvePlayable(item) }.onSuccess { playable -> try {
context.startActivity( runCatching { repo.resolvePlayable(item) }
PlayerActivity.intent( .onSuccess { playable ->
context = context, context.startActivity(
itemId = playable.itemId, PlayerActivity.intent(
url = playable.url, context = context,
title = playable.title, itemId = playable.itemId,
resumePositionMs = playable.resumePositionMs, url = playable.url,
), title = playable.title,
) resumePositionMs = playable.resumePositionMs,
logoUrl = playable.logoUrl,
requestStartedAtMs = playbackRequestedAtMs,
),
)
}
.onFailure {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
}
} finally {
launchingItem = null
} }
} }
} }
@@ -491,7 +850,7 @@ private fun HomeScreen(settings: Settings) {
.fillMaxSize() .fillMaxSize()
.offset(x = contentShift), .offset(x = contentShift),
) { ) {
val maintenanceMessage = homeState.maintenanceMessage val maintenanceMessage = liveMaintenance?.message ?: homeState.maintenanceMessage
if (maintenanceMessage != null) { if (maintenanceMessage != null) {
// The rows are gone but the rail is not: Settings and Switch user are // The rows are gone but the rail is not: Settings and Switch user are
// local, so there is no reason to strand the viewer here. // local, so there is no reason to strand the viewer here.
@@ -575,13 +934,13 @@ private fun HomeScreen(settings: Settings) {
returnItemId = item.id returnItemId = item.id
homeViewModel.focusItem(item) homeViewModel.focusItem(item)
homeViewModel.trackRowSelected(row.id, row.kind.name, item.id) homeViewModel.trackRowSelected(row.id, row.kind.name, item.id)
detailsItem = item if (item.membyPlayable) detailsItem = item
}, },
onItemLongPressed = { item -> onItemLongPressed = { item ->
returnRowId = row.id returnRowId = row.id
returnItemId = item.id returnItemId = item.id
homeViewModel.focusItem(item) homeViewModel.focusItem(item)
quickMenuItem = item if (item.membyPlayable) quickMenuItem = item
}, },
) )
} }
@@ -589,6 +948,11 @@ private fun HomeScreen(settings: Settings) {
} }
} }
} }
HomeClock(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 18.dp),
)
if (showSettings) { if (showSettings) {
BackHandler(onBack = { showSettings = false }) BackHandler(onBack = { showSettings = false })
SettingsSheet(editableServer = true, onClose = { showSettings = false }) SettingsSheet(editableServer = true, onClose = { showSettings = false })
@@ -612,14 +976,11 @@ private fun HomeScreen(settings: Settings) {
}, },
onAddProfile = { onAddProfile = {
showProfiles = false showProfiles = false
addProfile = true onAddProfile()
}, },
onClose = { showProfiles = false }, onClose = { showProfiles = false },
) )
} }
if (addProfile) {
SetupScreen(onCancel = { addProfile = false })
}
detailsItem?.let { selected -> detailsItem?.let { selected ->
BackHandler { BackHandler {
detailsItem = null detailsItem = null
@@ -656,9 +1017,15 @@ private fun HomeScreen(settings: Settings) {
}, },
) )
} }
launchingItem?.let { item ->
PlaybackLaunchOverlay(
item = item,
modifier = Modifier.fillMaxSize().zIndex(9f),
)
}
// Last in the Box, so it draws over everything — including the rail and any // Last in the Box, so it draws over everything — including the rail and any
// overlay that happened to be open when the check came back. // overlay that happened to be open when the check came back.
homeState.update?.let { update -> homeState.update?.takeIf { liveMaintenance == null }?.let { update ->
UpdateScreen( UpdateScreen(
update = update, update = update,
onDismiss = homeViewModel::dismissUpdatePrompt, onDismiss = homeViewModel::dismissUpdatePrompt,
@@ -668,6 +1035,79 @@ private fun HomeScreen(settings: Settings) {
} }
} }
@Composable
private fun PlaybackLaunchOverlay(item: BaseItem, modifier: Modifier = Modifier) {
val rotation by rememberInfiniteTransition(label = "playback-launch").animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 850, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "playback-logo-rotation",
)
Box(
modifier = modifier.background(Color(0xEE050708)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.width(82.dp)
.height(70.dp)
.graphicsLayer { rotationZ = rotation },
)
Text(
"Starting ${item.name}",
color = Color.White,
fontSize = 21.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"Preparing direct playback…",
color = Color(0xFF9FA7AD),
fontSize = 14.sp,
)
}
}
}
@Composable
private fun HomeClock(modifier: Modifier = Modifier) {
val context = LocalContext.current
val timeFormatter = remember(context) { DateFormat.getTimeFormat(context) }
var currentTime by remember { mutableStateOf(Date()) }
LaunchedEffect(Unit) {
while (true) {
currentTime = Date()
// Wake on the next minute boundary instead of drifting by a few seconds
// every time the app is left open.
val untilNextMinute = 60_000L - (System.currentTimeMillis() % 60_000L)
kotlinx.coroutines.delay(untilNextMinute)
}
}
Text(
text = timeFormatter.format(currentTime),
color = Color.White.copy(alpha = 0.86f),
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(Color(0xB30A0D10))
.padding(horizontal = 12.dp, vertical = 7.dp),
)
}
@Composable @Composable
private fun HomeArtworkPreloader( private fun HomeArtworkPreloader(
rows: List<HomeBrowseRow>, rows: List<HomeBrowseRow>,
@@ -804,13 +1244,18 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
.map { row -> .map { row ->
HomeBrowseRow( HomeBrowseRow(
id = row.id, id = row.id,
title = row.title, title = if (row.kind == "favorites") {
personalizedFavoritesTitle(settings.username)
} else {
row.title
},
items = row.items, items = row.items,
kind = when (row.kind) { kind = when (row.kind) {
"continue" -> MediaRowKind.CONTINUE "continue" -> MediaRowKind.CONTINUE
"nextup" -> MediaRowKind.NEXT_UP "nextup" -> MediaRowKind.NEXT_UP
"favorites" -> MediaRowKind.FAVORITES "favorites" -> MediaRowKind.FAVORITES
"shows" -> MediaRowKind.SHOWS "shows" -> MediaRowKind.SHOWS
"schedule" -> MediaRowKind.SHOWS
// Recommendation strips, "latest", and any kind a future server // Recommendation strips, "latest", and any kind a future server
// sends get poster cards, which suit mixed movie/series rows. // sends get poster cards, which suit mixed movie/series rows.
else -> MediaRowKind.MOVIES else -> MediaRowKind.MOVIES
@@ -821,6 +1266,7 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
"nextup" -> "You're all caught up" "nextup" -> "You're all caught up"
"favorites" -> "Your favourites will appear here" "favorites" -> "Your favourites will appear here"
"latest" -> "No recent movies found" "latest" -> "No recent movies found"
"schedule" -> "No monitored shows are airing today"
else -> "Nothing to show here yet" else -> "Nothing to show here yet"
}, },
showSecondaryMetadata = settings.showHomeCardMetadata, showSecondaryMetadata = settings.showHomeCardMetadata,
@@ -862,7 +1308,7 @@ private fun homeRowsFor(
) )
val favorites = HomeBrowseRow( val favorites = HomeBrowseRow(
id = "favorites", id = "favorites",
title = "Favourites", title = personalizedFavoritesTitle(settings.username),
items = state.favorites, items = state.favorites,
kind = MediaRowKind.FAVORITES, kind = MediaRowKind.FAVORITES,
loading = HomeSection.FAVORITES in state.loading, loading = HomeSection.FAVORITES in state.loading,
@@ -914,6 +1360,33 @@ private fun homeRowsFor(
} }
} }
/**
* Turns Emby account-style usernames into a friendlier home-screen name.
*
* Household accounts commonly use a trailing capital as a disambiguating surname
* initial (PeterC, PaulR). Only that clear camel-case shape is trimmed, so ordinary
* usernames such as PJ, CHRIS, alice, or MattCohen are left alone.
*/
internal fun friendlyProfileName(username: String?): String? {
val name = username?.trim().orEmpty()
if (name.isEmpty()) return null
return if (
name.length >= 3 &&
name.last().isUpperCase() &&
name[name.lastIndex - 1].isLowerCase()
) {
name.dropLast(1)
} else {
name
}
}
internal fun personalizedFavoritesTitle(username: String?): String {
val name = friendlyProfileName(username) ?: return "Favorites"
val possessive = if (name.endsWith("s", ignoreCase = true)) "$name'" else "$name's"
return "$possessive Favorites"
}
@Composable @Composable
private fun HeaderAction(label: String, onClick: () -> Unit) { private fun HeaderAction(label: String, onClick: () -> Unit) {
var focused by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) }
@@ -1,12 +1,20 @@
package com.ponzischeme89.memby.ui.player package com.ponzischeme89.memby.ui.player
import android.app.AlertDialog import android.app.AlertDialog
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.SystemClock
import android.text.format.DateFormat
import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.FrameLayout import android.view.animation.LinearInterpolator
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
@@ -15,14 +23,23 @@ import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import coil.load
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.ui.MainActivity
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Date
import kotlin.math.ceil
/** /**
* Fullscreen Media3 player with native stream-track selection. Press Menu while * Fullscreen Media3 player with native stream-track selection. Press Menu while
@@ -37,6 +54,12 @@ class PlayerActivity : ComponentActivity() {
private var playbackStarted = false private var playbackStarted = false
private var stopReported = false private var stopReported = false
private var itemId: String? = null private var itemId: String? = null
private var remainingView: TextView? = null
private var finishTimeView: TextView? = null
private var loadingView: View? = null
private var loadingAnimator: ObjectAnimator? = null
private var renderedFirstFrame = false
private var requestStartedAtMs = 0L
@UnstableApi @UnstableApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -44,6 +67,10 @@ class PlayerActivity : ComponentActivity() {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val url = intent.getStringExtra(EXTRA_URL) val url = intent.getStringExtra(EXTRA_URL)
requestStartedAtMs = intent.getLongExtra(
EXTRA_REQUEST_STARTED_AT_MS,
SystemClock.elapsedRealtime(),
)
itemId = intent.getStringExtra(EXTRA_ITEM_ID) itemId = intent.getStringExtra(EXTRA_ITEM_ID)
val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L) val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L)
if (url.isNullOrBlank()) { if (url.isNullOrBlank()) {
@@ -51,25 +78,54 @@ class PlayerActivity : ComponentActivity() {
return return
} }
val view = PlayerView(this).apply { setContentView(R.layout.activity_player)
layoutParams = FrameLayout.LayoutParams( val view = findViewById<PlayerView>(R.id.player_view).apply {
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
useController = true useController = true
setShowSubtitleButton(true) setShowSubtitleButton(true)
controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS
} }
setContentView(view)
playerView = view playerView = view
loadingView = findViewById(R.id.playback_loading)
loadingAnimator = ObjectAnimator.ofFloat(
findViewById<ImageView>(R.id.playback_loading_logo),
View.ROTATION,
0f,
360f,
).apply {
duration = 850L
repeatCount = ValueAnimator.INFINITE
interpolator = LinearInterpolator()
start()
}
remainingView = findViewById(R.id.player_remaining)
finishTimeView = findViewById(R.id.player_finish_time)
bindTitleArtwork(
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
logoUrl = intent.getStringExtra(EXTRA_LOGO_URL),
)
val selector = DefaultTrackSelector(this) val selector = DefaultTrackSelector(this)
val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(
MIN_BUFFER_MS,
MAX_BUFFER_MS,
BUFFER_FOR_PLAYBACK_MS,
BUFFER_AFTER_REBUFFER_MS,
)
.setPrioritizeTimeOverSizeThresholds(true)
.build()
player = ExoPlayer.Builder(this) player = ExoPlayer.Builder(this)
.setTrackSelector(selector) .setTrackSelector(selector)
.setLoadControl(loadControl)
.build() .build()
.also { playback -> .also { playback ->
view.player = playback view.player = playback
playback.addListener(object : Player.Listener { playback.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_BUFFERING -> showPlaybackLoading()
Player.STATE_READY -> if (renderedFirstFrame) hidePlaybackLoading()
}
if (playbackState == Player.STATE_READY && !playbackStarted) { if (playbackState == Player.STATE_READY && !playbackStarted) {
playbackStarted = true playbackStarted = true
reportStarted(playback.currentPosition) reportStarted(playback.currentPosition)
@@ -80,11 +136,95 @@ class PlayerActivity : ComponentActivity() {
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
if (playbackStarted) reportProgress(playback.currentPosition, isPaused = !isPlaying) if (playbackStarted) reportProgress(playback.currentPosition, isPaused = !isPlaying)
} }
override fun onEvents(player: Player, events: Player.Events) {
updatePlaybackTiming(player)
}
override fun onRenderedFirstFrame() {
renderedFirstFrame = true
hidePlaybackLoading()
Log.i(
PLAYBACK_LOG_TAG,
"First video frame rendered in " +
"${SystemClock.elapsedRealtime() - requestStartedAtMs} ms",
)
}
}) })
playback.setMediaItem(MediaItem.fromUri(url), resumePositionMs) playback.setMediaItem(MediaItem.fromUri(url), resumePositionMs)
playback.playWhenReady = true playback.playWhenReady = true
playback.prepare() playback.prepare()
} }
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
ServiceLocator.maintenance.notice.filterNotNull().collect {
interruptForMaintenance()
}
}
}
}
private fun showPlaybackLoading() {
loadingView?.visibility = View.VISIBLE
if (loadingAnimator?.isStarted != true) loadingAnimator?.start()
}
private fun hidePlaybackLoading() {
loadingView?.visibility = View.GONE
loadingAnimator?.cancel()
}
private fun bindTitleArtwork(title: String, logoUrl: String?) {
val logo = findViewById<ImageView>(R.id.player_title_logo)
val fallback = findViewById<TextView>(R.id.player_title).apply {
text = title.ifBlank { "Now playing" }
}
if (logoUrl.isNullOrBlank()) {
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
return
}
logo.load(logoUrl) {
crossfade(false)
listener(
onSuccess = { _, _ ->
logo.visibility = View.VISIBLE
fallback.visibility = View.GONE
},
onError = { _, _ ->
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
},
)
}
}
private fun updatePlaybackTiming(playback: Player) {
val duration = playback.duration
if (duration == C.TIME_UNSET || duration <= 0L) {
remainingView?.text = if (playback.isCurrentMediaItemLive) "Live" else "Loading duration…"
finishTimeView?.text = ""
return
}
val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L)
val speed = playback.playbackParameters.speed.coerceAtLeast(0.1f)
val wallClockRemainingMs = (remainingMs / speed).toLong()
remainingView?.text = formatRemaining(wallClockRemainingMs)
val finishAt = System.currentTimeMillis() + wallClockRemainingMs
finishTimeView?.text = "Ends at ${DateFormat.getTimeFormat(this).format(Date(finishAt))}"
}
private fun interruptForMaintenance() {
player?.pause()
progressJob?.cancel()
startActivity(
Intent(this, MainActivity::class.java).addFlags(
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP,
),
)
finish()
} }
override fun dispatchKeyEvent(event: KeyEvent): Boolean { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@@ -159,6 +299,7 @@ class PlayerActivity : ComponentActivity() {
override fun onDestroy() { override fun onDestroy() {
progressJob?.cancel() progressJob?.cancel()
loadingAnimator?.cancel()
val playback = player val playback = player
if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) { if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) {
stopReported = true stopReported = true
@@ -189,7 +330,10 @@ class PlayerActivity : ComponentActivity() {
progressJob = lifecycleScope.launch { progressJob = lifecycleScope.launch {
while (isActive) { while (isActive) {
delay(PROGRESS_INTERVAL_MS) delay(PROGRESS_INTERVAL_MS)
player?.let { reportProgress(it.currentPosition, isPaused = !it.isPlaying) } player?.let {
reportProgress(it.currentPosition, isPaused = !it.isPlaying)
updatePlaybackTiming(it)
}
} }
} }
} }
@@ -201,6 +345,8 @@ class PlayerActivity : ComponentActivity() {
private const val EXTRA_ITEM_ID = "extra_item_id" private const val EXTRA_ITEM_ID = "extra_item_id"
private const val EXTRA_TITLE = "extra_title" private const val EXTRA_TITLE = "extra_title"
private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms" private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms"
private const val EXTRA_LOGO_URL = "extra_logo_url"
private const val EXTRA_REQUEST_STARTED_AT_MS = "extra_request_started_at_ms"
fun intent( fun intent(
context: Context, context: Context,
@@ -215,14 +361,34 @@ class PlayerActivity : ComponentActivity() {
url: String, url: String,
title: String?, title: String?,
resumePositionMs: Long = 0L, resumePositionMs: Long = 0L,
logoUrl: String? = null,
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
): Intent = ): Intent =
Intent(context, PlayerActivity::class.java).apply { Intent(context, PlayerActivity::class.java).apply {
itemId?.let { putExtra(EXTRA_ITEM_ID, it) } itemId?.let { putExtra(EXTRA_ITEM_ID, it) }
putExtra(EXTRA_URL, url) putExtra(EXTRA_URL, url)
putExtra(EXTRA_TITLE, title) putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs) putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs)
logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) }
putExtra(EXTRA_REQUEST_STARTED_AT_MS, requestStartedAtMs)
} }
private const val PROGRESS_INTERVAL_MS = 10_000L private const val PROGRESS_INTERVAL_MS = 10_000L
private const val CONTROLLER_TIMEOUT_MS = 6_000
private const val MIN_BUFFER_MS = 10_000
private const val MAX_BUFFER_MS = 30_000
private const val BUFFER_FOR_PLAYBACK_MS = 750
private const val BUFFER_AFTER_REBUFFER_MS = 1_500
private const val PLAYBACK_LOG_TAG = "MembyPlayback"
internal fun formatRemaining(durationMs: Long): String {
val totalMinutes = ceil(durationMs.coerceAtLeast(0L) / 60_000.0).toLong()
return when {
totalMinutes <= 1L -> "Less than a minute remaining"
totalMinutes < 60L -> "$totalMinutes min remaining"
totalMinutes % 60L == 0L -> "${totalMinutes / 60L} hr remaining"
else -> "${totalMinutes / 60L} hr ${totalMinutes % 60L} min remaining"
}
}
} }
} }
+3
View File
@@ -1,4 +1,7 @@
<resources> <resources>
<string name="player_title_logo">Title logo</string>
<string name="playback_loading">Starting playback…</string>
<string name="playback_loading_hint">Connecting directly to Emby</string>
<string name="app_name">Memby</string> <string name="app_name">Memby</string>
<string name="screensaver_name">Memby Screensaver</string> <string name="screensaver_name">Memby Screensaver</string>
<string name="dream_description">Memby movie &amp; TV backdrops</string> <string name="dream_description">Memby movie &amp; TV backdrops</string>
@@ -79,6 +79,44 @@ class GatewayPayloadTest {
assertEquals("Solaris", home.rows.last().items.single().name) assertEquals("Solaris", home.rows.last().items.single().name)
} }
@Test
fun `decodes the informational Sonarr schedule row`() {
val payload = """
{
"rows": [{
"id":"sonarr-airing-today",
"title":"Shows airing today",
"kind":"schedule",
"items":[{
"Id":"sonarr:7:42",
"Name":"Northbound",
"Type":"MembySonarrEpisode",
"ImageTags":{"Primary":"sonarr"},
"MembySource":"sonarr",
"MembyEpisodeTitle":"The Crossing",
"MembyEpisodeCode":"S02E04",
"MembyAirLabel":"Airs today at 8:00 PM",
"MembyAvailability":"downloading",
"MembyAvailabilityText":"Downloading",
"MembyPlayable":false
}]
}],
"continueWatching":[],
"nextUp":[],
"favorites":[],
"latestMovies":[],
"partial":false
}
""".trimIndent()
val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()
assertTrue(item.isSonarrSchedule)
assertEquals("S02E04", item.membyEpisodeCode)
assertEquals("Downloading", item.membyAvailabilityText)
assertEquals(false, item.membyPlayable)
}
@Test @Test
fun `a home payload without rows still decodes`() { fun `a home payload without rows still decodes`() {
// The gateway omits recommendation rows while they are still building, and an // The gateway omits recommendation rows while they are still building, and an
@@ -106,4 +144,14 @@ class GatewayPayloadTest {
assertEquals(42_000L, playback.resumePositionMs) assertEquals(42_000L, playback.resumePositionMs)
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream")) assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
} }
@Test
fun `device allowance response becomes a specific sign-in error`() {
val error = parseDeviceLimit(
"""{"error":"device_limit_reached","activeClients":3,"maxClientsPerUser":3}""",
)
assertEquals(3, error?.activeClients)
assertEquals(3, error?.maxClients)
}
} }
@@ -43,6 +43,32 @@ class ServerHomeRowsTest {
assertEquals("Recommended", rows.last().title) assertEquals("Recommended", rows.last().title)
} }
@Test
fun `favorites row uses the friendly profile name`() {
val peter = serverHomeRows(
HomeUiState(rows = serverRows, loading = emptySet()),
Settings(username = "PeterC"),
)
val paul = serverHomeRows(
HomeUiState(rows = serverRows, loading = emptySet()),
Settings(username = "PaulR"),
)
assertEquals("Peter's Favorites", peter.first { it.id == "favorites" }.title)
assertEquals("Paul's Favorites", paul.first { it.id == "favorites" }.title)
}
@Test
fun `friendly profile name only removes a clear trailing initial`() {
assertEquals("Peter", friendlyProfileName("PeterC"))
assertEquals("Paul", friendlyProfileName(" PaulR "))
assertEquals("MattCohen", friendlyProfileName("MattCohen"))
assertEquals("CHRIS", friendlyProfileName("CHRIS"))
assertEquals("PJ", friendlyProfileName("PJ"))
assertEquals("Chris' Favorites", personalizedFavoritesTitle("Chris"))
assertEquals("Favorites", personalizedFavoritesTitle(null))
}
@Test @Test
fun `disabling a section hides its rows but never the recommendations`() { fun `disabling a section hides its rows but never the recommendations`() {
val settings = Settings(homeSections = "continue") val settings = Settings(homeSections = "continue")
@@ -67,6 +93,20 @@ class ServerHomeRowsTest {
assertEquals(MediaRowKind.MOVIES, rows.getValue("similar:sev").kind) assertEquals(MediaRowKind.MOVIES, rows.getValue("similar:sev").kind)
} }
@Test
fun `Sonarr schedule rows use show cards and cannot be hidden by old preferences`() {
val schedule = row("sonarr-airing-today", "schedule", "sonarr:7:42")
val rows = serverHomeRows(
HomeUiState(rows = listOf(schedule), loading = emptySet()),
Settings(homeSections = ""),
)
assertEquals(1, rows.size)
assertEquals(MediaRowKind.SHOWS, rows.single().kind)
assertEquals("No monitored shows are airing today", rows.single().emptyMessage)
}
@Test @Test
fun `an unknown row kind from a newer server still renders`() { fun `an unknown row kind from a newer server still renders`() {
val rows = serverHomeRows( val rows = serverHomeRows(
+21
View File
@@ -8,6 +8,11 @@ services:
- "${MEMBY_PORT:-8080}:8080" - "${MEMBY_PORT:-8080}:8080"
environment: environment:
MEMBY_LISTEN_ADDR: ":8080" MEMBY_LISTEN_ADDR: ":8080"
# INFO keeps Docker logs concise. Temporarily use DEBUG to include successful
# health checks, maintenance polling and artwork requests.
MEMBY_LOG_LEVEL: "${MEMBY_LOG_LEVEL:-INFO}"
# Local day boundaries and schedule labels for Sonarr's "airing today" row.
MEMBY_TIMEZONE: "${MEMBY_TIMEZONE:-Pacific/Auckland}"
# How the gateway reaches Emby. # How the gateway reaches Emby.
MEMBY_EMBY_URL: "${MEMBY_EMBY_URL:?set MEMBY_EMBY_URL in .env}" MEMBY_EMBY_URL: "${MEMBY_EMBY_URL:?set MEMBY_EMBY_URL in .env}"
# What the TVs are told to stream from. Only set this when it differs from the # What the TVs are told to stream from. Only set this when it differs from the
@@ -16,15 +21,30 @@ services:
MEMBY_DATABASE_URL: "postgres://memby:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/memby?sslmode=disable" MEMBY_DATABASE_URL: "postgres://memby:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/memby?sslmode=disable"
MEMBY_REDIS_URL: "redis://redis:6379/0" MEMBY_REDIS_URL: "redis://redis:6379/0"
MEMBY_HOME_TTL: "${MEMBY_HOME_TTL:-60s}" MEMBY_HOME_TTL: "${MEMBY_HOME_TTL:-60s}"
# Strict per-Emby-user TV allowance. Signing the same physical TV in again
# replaces its token and does not consume another slot.
MEMBY_MAX_CLIENTS_PER_USER: "${MEMBY_MAX_CLIENTS_PER_USER:-1}"
# Unset disables /admin entirely — the library import, maintenance switch and # Unset disables /admin entirely — the library import, maintenance switch and
# analytics page all live behind it. # analytics page all live behind it.
MEMBY_ADMIN_TOKEN: "${MEMBY_ADMIN_TOKEN:-}" MEMBY_ADMIN_TOKEN: "${MEMBY_ADMIN_TOKEN:-}"
# CI publishes signed APKs here. The dedicated token does not grant access to the
# rest of the admin API.
MEMBY_PUBLIC_URL: "${MEMBY_PUBLIC_URL:-https://mserver.sublogue.com}"
MEMBY_RELEASE_DIR: "/data/releases"
MEMBY_RELEASE_PUBLISH_TOKEN: "${MEMBY_RELEASE_PUBLISH_TOKEN:-}"
# Hourly incremental import: enough for episodes landing through the day, and # Hourly incremental import: enough for episodes landing through the day, and
# films appearing weekly ride along. # films appearing weekly ride along.
MEMBY_SYNC_INTERVAL: "${MEMBY_SYNC_INTERVAL:-1h}" MEMBY_SYNC_INTERVAL: "${MEMBY_SYNC_INTERVAL:-1h}"
MEMBY_SYNC_ON_START: "${MEMBY_SYNC_ON_START:-false}" MEMBY_SYNC_ON_START: "${MEMBY_SYNC_ON_START:-false}"
MEMBY_SYNC_USER_ID: "${MEMBY_SYNC_USER_ID:-}" MEMBY_SYNC_USER_ID: "${MEMBY_SYNC_USER_ID:-}"
MEMBY_SYNC_API_KEY: "${MEMBY_SYNC_API_KEY:-}" MEMBY_SYNC_API_KEY: "${MEMBY_SYNC_API_KEY:-}"
# Optional read-only Sonarr calendar integration. Both values are required to
# enable it; the API key remains inside the gateway.
MEMBY_SONARR_URL: "${MEMBY_SONARR_URL:-}"
MEMBY_SONARR_API_KEY: "${MEMBY_SONARR_API_KEY:-}"
MEMBY_SONARR_TTL: "${MEMBY_SONARR_TTL:-5m}"
volumes:
- memby-releases:/data/releases
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -66,3 +86,4 @@ services:
volumes: volumes:
memby-postgres: memby-postgres:
memby-releases:
+1 -1
View File
@@ -15,7 +15,7 @@ memby.serverUrl=https://molise.bounceme.net
# The Memby gateway container (server/). When set, the app talks to it instead of Emby # The Memby gateway container (server/). When set, the app talks to it instead of Emby
# and the address above is only used as the fallback path. Blank = talk to Emby directly. # and the address above is only used as the fallback path. Blank = talk to Emby directly.
memby.gatewayUrl=https://memby.bounceme.net memby.gatewayUrl=https://mserver.sublogue.com
# Enabled parallel sync for Gradle 9.4+ # Enabled parallel sync for Gradle 9.4+
org.gradle.tooling.parallel=true org.gradle.tooling.parallel=true
+4 -3
View File
@@ -52,12 +52,13 @@ if ($Version) {
# Same scheme as app/build.gradle.kts: major*10000 + minor*100 + patch. # Same scheme as app/build.gradle.kts: major*10000 + minor*100 + patch.
$code = [int]$parts[0] * 10000 + [int]$parts[1] * 100 + [int]$parts[2] $code = [int]$parts[0] * 10000 + [int]$parts[1] * 100 + [int]$parts[2]
$gradle = $gradle -replace 'versionCode = \d+', "versionCode = $code" $gradle = $gradle -replace 'val defaultVersionName = "[^"]*"', "val defaultVersionName = `"$Version`""
$gradle = $gradle -replace 'versionName = "[^"]*"', "versionName = `"$Version`""
Set-Content -LiteralPath $gradleFile -Value $gradle -NoNewline Set-Content -LiteralPath $gradleFile -Value $gradle -NoNewline
Write-Host "Set version $Version (versionCode $code)" -ForegroundColor Cyan Write-Host "Set version $Version (versionCode $code)" -ForegroundColor Cyan
} else { } else {
if ($gradle -notmatch 'versionName = "([^"]*)"') { throw 'Could not read versionName from app/build.gradle.kts' } if ($gradle -notmatch 'val defaultVersionName = "([^"]*)"') {
throw 'Could not read defaultVersionName from app/build.gradle.kts'
}
$Version = $Matches[1] $Version = $Matches[1]
Write-Host "Building existing version $Version" -ForegroundColor Cyan Write-Host "Building existing version $Version" -ForegroundColor Cyan
} }
+2
View File
@@ -8,6 +8,7 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN mkdir -p /out/releases
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
-ldflags="-s -w" -o /out/memby-server ./cmd/memby-server -ldflags="-s -w" -o /out/memby-server ./cmd/memby-server
@@ -16,6 +17,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app WORKDIR /app
COPY --from=build /out/memby-server /app/memby-server COPY --from=build /out/memby-server /app/memby-server
COPY --from=build --chown=nonroot:nonroot /out/releases /data/releases
EXPOSE 8080 EXPOSE 8080
USER nonroot:nonroot USER nonroot:nonroot
ENTRYPOINT ["/app/memby-server"] ENTRYPOINT ["/app/memby-server"]
+93 -9
View File
@@ -35,6 +35,17 @@ go run ./cmd/memby-server # needs Postgres + Redis reachable
On Windows, `go` may need `-buildvcs=false` when the working tree has no usable `.git`. On Windows, `go` may need `-buildvcs=false` when the working tree has no usable `.git`.
## Logs
The server writes one compact, structured text line per event, designed for
`docker compose logs -f server`. At the default `MEMBY_LOG_LEVEL=INFO`, successful
health checks, live maintenance polls and artwork requests are hidden; warnings and
failures from those routes are still shown.
Set `MEMBY_LOG_LEVEL=DEBUG` temporarily and recreate the server container when those
high-frequency requests are useful during diagnosis. Library imports log their start,
progress after each 500-item page, and final counts and duration.
## API ## API
All `/v1` routes need `Authorization: Bearer <token>` from `/v1/auth/login`. Image URLs All `/v1` routes need `Authorization: Bearer <token>` from `/v1/auth/login`. Image URLs
@@ -43,9 +54,11 @@ headers attached.
| Method | Path | Purpose | | Method | Path | Purpose |
| --- | --- | --- | | --- | --- | --- |
| POST | `/v1/auth/login` | Emby credentials in, gateway token out | | POST | `/v1/auth/login` | Emby credentials + device identity in, gateway token out |
| GET | `/v1/auth/policy` | Public client allowance used by the sign-in screen |
| POST | `/v1/auth/logout` | Retire this device's token | | POST | `/v1/auth/logout` | Retire this device's token |
| GET | `/v1/auth/session` | Confirm a stored token is still valid | | GET | `/v1/auth/session` | Confirm a stored token is still valid |
| GET | `/v1/status` | Lightweight live maintenance state; remains available during maintenance |
| GET | `/v1/home?limit=` | **Every launcher row in one response** | | GET | `/v1/home?limit=` | **Every launcher row in one response** |
| GET | `/v1/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild | | GET | `/v1/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild |
| GET | `/v1/update` | Whether this client (per `X-Memby-Version`) must update | | GET | `/v1/update` | Whether this client (per `X-Memby-Version`) must update |
@@ -71,10 +84,14 @@ four fixed rows repeated flat for the client's offline cache:
"rows": [ "rows": [
{"id": "continue", "title": "Continue Watching", "kind": "continue", "items": [...]}, {"id": "continue", "title": "Continue Watching", "kind": "continue", "items": [...]},
{"id": "next-up", "title": "Next Up", "kind": "nextup", "items": [...]}, {"id": "next-up", "title": "Next Up", "kind": "nextup", "items": [...]},
{"id": "sonarr-airing-today", "title": "Shows airing today", "kind": "schedule", "items": [...]},
{"id": "favorites", "title": "Favourites", "kind": "favorites", "items": [...]}, {"id": "favorites", "title": "Favourites", "kind": "favorites", "items": [...]},
{"id": "latest-movies", "title": "Recently Added Movies", "kind": "latest", "items": [...]}, {"id": "latest-movies", "title": "Recently Added Movies", "kind": "latest", "items": [...]},
{"id": "similar:sev", "title": "Because you watched Severance", "kind": "similar", "items": [...]}, {"id": "similar:sev", "title": "Because you watched Severance", "kind": "similar", "items": [...]},
{"id": "recommended", "title": "Recommended from your watching history", "kind": "recommended", "items": [...]} {"id": "recommended", "title": "Recommended from your watching history", "kind": "recommended", "items": [...]},
{"id": "curated:apple-tv", "title": "Apple TV+ Shows", "kind": "shows", "items": [...]},
{"id": "curated:drama-shows", "title": "Drama TV Shows", "kind": "shows", "items": [...]},
{"id": "curated:comedy-shows", "title": "Comedy TV Shows", "kind": "shows", "items": [...]}
], ],
"continueWatching": [...], "nextUp": [...], "favorites": [...], "latestMovies": [...], "continueWatching": [...], "nextUp": [...], "favorites": [...], "latestMovies": [...],
"partial": false "partial": false
@@ -98,11 +115,16 @@ invented — nobody opted out of a row that did not exist when they last opened
recent), favourites add a smaller fixed weight, and candidates are unplayed titles in the recent), favourites add a smaller fixed weight, and candidates are unplayed titles in the
top three genres scored by affinity + a mild community-rating nudge. Titles tagged with top three genres scored by affinity + a mild community-rating nudge. Titles tagged with
many genres get a `sqrt(n)` penalty so genre-stuffing cannot buy a top slot. many genres get a `sqrt(n)` penalty so genre-stuffing cannot buy a top slot.
- **Curated TV shelves** — Apple TV+, Drama and Comedy membership is filtered from the
imported Postgres catalogue. Within each shelf, unseen shows are ranked using the same
personal genre/studio profile; the shelves themselves are ordered by affinity too.
A new profile falls back to community rating until it has viewing history.
Rows shorter than four items are dropped, and a user with no history gets no rows at all Rows shorter than four items are dropped, and a user with no history gets no rows at all
rather than a strip of noise. rather than a strip of noise, except curated shelves: these remain useful with their
quality-ranked fallback.
**The home screen never waits on the engine.** Rows live in their own `r:<userId>:rows` **The home screen never waits on the engine.** Rows live in their own `r:<userId>:rows:v2`
cache key with a long TTL (2h). A cache miss serves home immediately without them and cache key with a long TTL (2h). A cache miss serves home immediately without them and
triggers a background rebuild — deduplicated per user, so four TVs waking together do the triggers a background rebuild — deduplicated per user, so four TVs waking together do the
work once. Because the key sits outside the `u:` namespace, a favourite toggle does not work once. Because the key sits outside the `u:` namespace, a favourite toggle does not
@@ -112,11 +134,28 @@ that genuinely changes viewing history.
The scoring is pure and unit-tested (`profile_test.go`), and the row assembly runs against The scoring is pure and unit-tested (`profile_test.go`), and the row assembly runs against
a fake Emby (`engine_test.go`), so neither needs a server to verify. a fake Emby (`engine_test.go`), so neither needs a server to verify.
Curated shelves require at least one completed library import because their genre/studio
filter runs against Postgres. Emby's exact studio naming is preserved during import;
Apple matching accepts `Apple TV+`, `Apple TV Plus` and `Apple Studios`.
Item payloads are Emby's own JSON, forwarded verbatim. That is deliberate: the Android Item payloads are Emby's own JSON, forwarded verbatim. That is deliberate: the Android
client already models this shape, so there is no second schema to keep in sync. client already models this shape, so there is no second schema to keep in sync.
`app/src/test/.../GatewayPayloadTest.kt` and `internal/api/api_test.go` pin the envelope `app/src/test/.../GatewayPayloadTest.kt` and `internal/api/api_test.go` pin the envelope
around it from both sides. around it from both sides.
### Sonarr schedule
When `MEMBY_SONARR_URL` and `MEMBY_SONARR_API_KEY` are set, the gateway reads Sonarr's
v3 calendar and inserts **Shows airing today** directly after Next Up. Cards show the
local air time, season/episode number, episode title and one of: upcoming, downloading,
awaiting download, unmonitored, or the exact time Sonarr added the episode file.
This row is informational. An episode listed before it is downloaded is not an Emby
item, so the TV deliberately does not offer Play, Favourite or Watched actions on it.
Sonarr poster and fanart requests are proxied through the gateway; its API key is never
sent to the TV. The shared Redis entry is refreshed every five minutes by default, not
once per user.
## Admin interface ## Admin interface
`http://<host>:8080/admin/` — a single self-contained page for library imports, the `http://<host>:8080/admin/` — a single self-contained page for library imports, the
@@ -167,8 +206,10 @@ candidate pool.
Takes Memby down independently of Emby: all `/v1` routes answer `503` with Takes Memby down independently of Emby: all `/v1` routes answer `503` with
`{"maintenance": true, "message": "…"}`, and the TV shows the operator's message instead of `{"maintenance": true, "message": "…"}`, and the TV shows the operator's message instead of
a network error. `/healthz`, `/readyz` and `/admin` stay up — they are what you need while a network error. The exact `/v1/status` control route stays up too: open clients poll it
the app is deliberately off. every ten seconds, stop active playback, and move immediately to the maintenance screen.
`/healthz`, `/readyz` and `/admin` also stay up — they are what you need while the app is
deliberately off.
The switch lives in Postgres, not memory, so a restart cannot quietly bring the app back The switch lives in Postgres, not memory, so a restart cannot quietly bring the app back
up mid-repair. Each instance caches it and re-reads every 30 seconds, so toggling it up mid-repair. Each instance caches it and re-reads every 30 seconds, so toggling it
@@ -177,8 +218,40 @@ directly in the database works too.
## App update policy ## App update policy
The gateway decides whether a TV may keep running its current build. Clients send The gateway decides whether a TV may keep running its current build. Clients send
`X-Memby-Version` on every request and ask `GET /v1/update` on each launch; the verdict is `X-Memby-Version` on every request and ask `GET /v1/update` on each launch and hourly
`none`, `optional` or `mandatory`. while left open; the verdict is `none`, `optional` or `mandatory`.
### Automated tagged releases
`.gitea/workflows/release.yml` turns a pushed semantic-version tag into an update:
1. Gitea Actions derives the Android version from a tag such as `v0.1.54`.
2. It runs the tests and builds the APK with the established release signing key.
3. It uploads the signed APK to `POST /admin/api/release`.
4. The gateway stores the APK in its `memby-releases` volume and makes it the latest
optional update. Older TVs prompt on their next check.
The repository needs an Actions runner with the `ubuntu-latest` label and these Actions
secrets:
| Secret | Purpose |
| --- | --- |
| `ANDROID_KEYSTORE_BASE64` | Existing release keystore, base64 encoded as one line |
| `ANDROID_KEYSTORE_PASSWORD` | Keystore password |
| `ANDROID_KEY_ALIAS` | Signing alias |
| `ANDROID_KEY_PASSWORD` | Key password |
| `MEMBY_RELEASE_PUBLISH_TOKEN` | Same dedicated value configured on the gateway |
Enable repository Actions and give the job permission to read contents. Then releasing
from any clone is:
```bash
git tag -a v0.1.54 -m "Personalised favourites"
git push origin main v0.1.54
```
Tags are the release boundary; an ordinary branch push never publishes an APK. Android
will only accept updates signed by the same keystore as the installed app.
Set it on the admin page: **latest version**, **APK URL** (normally the same file the Set it on the admin page: **latest version**, **APK URL** (normally the same file the
landing page serves), release notes, and a **Require this update** toggle. landing page serves), release notes, and a **Require this update** toggle.
@@ -229,7 +302,9 @@ served but never cached. The `r:` namespace is deliberately excluded from that w
Recommendations above). Recommendations above).
Postgres holds only sessions. It is the durable half: losing Redis costs a cold cache, Postgres holds only sessions. It is the durable half: losing Redis costs a cold cache,
losing Postgres signs everyone out. losing Postgres signs everyone out. A session is unique per Emby user and stable device
ID; signing the same TV in again rotates its token, while a new TV is rejected once
`MEMBY_MAX_CLIENTS_PER_USER` is reached.
## Configuration ## Configuration
@@ -240,18 +315,27 @@ losing Postgres signs everyone out.
| `MEMBY_DATABASE_URL` | *required* | Postgres DSN | | `MEMBY_DATABASE_URL` | *required* | Postgres DSN |
| `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | | | `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | |
| `MEMBY_LISTEN_ADDR` | `:8080` | | | `MEMBY_LISTEN_ADDR` | `:8080` | |
| `MEMBY_LOG_LEVEL` | `INFO` | Use `DEBUG` for successful probe, status-poll and artwork requests |
| `MEMBY_TIMEZONE` | `Pacific/Auckland` | Local day and time labels for schedule rows |
| `MEMBY_CLIENT_NAME` | `Memby` | Shown in Emby's device list | | `MEMBY_CLIENT_NAME` | `Memby` | Shown in Emby's device list |
| `MEMBY_HOME_TTL` | `60s` | Also `MEMBY_ITEM_TTL`, `MEMBY_SEARCH_TTL`, `MEMBY_SCREENSAVER_TTL` | | `MEMBY_HOME_TTL` | `60s` | Also `MEMBY_ITEM_TTL`, `MEMBY_SEARCH_TTL`, `MEMBY_SCREENSAVER_TTL` |
| `MEMBY_RECOMMEND_TTL` | `2h` | How long computed recommendation rows stay warm | | `MEMBY_RECOMMEND_TTL` | `2h` | How long computed recommendation rows stay warm |
| `MEMBY_RECOMMEND_TIMEOUT` | `60s` | Bounds a background rebuild | | `MEMBY_RECOMMEND_TIMEOUT` | `60s` | Bounds a background rebuild |
| `MEMBY_ADMIN_TOKEN` | *empty* | Enables `/admin`. Empty = admin disabled | | `MEMBY_ADMIN_TOKEN` | *empty* | Enables `/admin`. Empty = admin disabled |
| `MEMBY_PUBLIC_URL` | *empty* | Public gateway origin used for APK download links |
| `MEMBY_RELEASE_DIR` | `/data/releases` | Persistent signed APK directory |
| `MEMBY_RELEASE_PUBLISH_TOKEN` | *empty* | Enables the CI-only release upload endpoint |
| `MEMBY_SYNC_INTERVAL` | `1h` | Incremental import cadence; `0` disables | | `MEMBY_SYNC_INTERVAL` | `1h` | Incremental import cadence; `0` disables |
| `MEMBY_SYNC_TIMEOUT` | `30m` | Bounds one import | | `MEMBY_SYNC_TIMEOUT` | `30m` | Bounds one import |
| `MEMBY_SYNC_ON_START` | `false` | Import at boot | | `MEMBY_SYNC_ON_START` | `false` | Import at boot |
| `MEMBY_SONARR_URL` | *empty* | Sonarr address reachable by the gateway; empty disables integration |
| `MEMBY_SONARR_API_KEY` | *empty* | Sonarr Settings → General → Security API key |
| `MEMBY_SONARR_TTL` | `5m` | Shared Redis lifetime for today's calendar |
| `MEMBY_SYNC_USER_ID` / `MEMBY_SYNC_API_KEY` | *empty* | Emby service account for imports | | `MEMBY_SYNC_USER_ID` / `MEMBY_SYNC_API_KEY` | *empty* | Emby service account for imports |
| `MEMBY_ANALYTICS_RETENTION` | `2160h` (90d) | Raw row events are pruned past this | | `MEMBY_ANALYTICS_RETENTION` | `2160h` (90d) | Raw row events are pruned past this |
| `MEMBY_SESSION_CACHE_TTL` | `5m` | How long a token lookup stays in Redis | | `MEMBY_SESSION_CACHE_TTL` | `5m` | How long a token lookup stays in Redis |
| `MEMBY_SESSION_IDLE_EXPIRY` | `2160h` (90d) | Unused tokens are swept every 6h | | `MEMBY_SESSION_IDLE_EXPIRY` | `2160h` (90d) | Unused tokens are swept every 6h |
| `MEMBY_MAX_CLIENTS_PER_USER` | `1` | Strict maximum number of distinct signed-in TVs per Emby user |
| `MEMBY_UPSTREAM_TIMEOUT` | `20s` | | | `MEMBY_UPSTREAM_TIMEOUT` | `20s` | |
## Security notes ## Security notes
+39 -2
View File
@@ -4,6 +4,7 @@ package main
import ( import (
"context" "context"
"encoding/hex"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
@@ -20,7 +21,9 @@ import (
"github.com/ponzischeme89/memby/server/internal/config" "github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/library" "github.com/ponzischeme89/memby/server/internal/library"
"github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/store"
) )
@@ -37,7 +40,8 @@ func main() {
return return
} }
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) logLevel := logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL"))
log := logging.New(os.Stdout, logLevel)
if err := run(log); err != nil { if err := run(log); err != nil {
log.Error("fatal", "error", err) log.Error("fatal", "error", err)
@@ -82,6 +86,34 @@ func run(log *slog.Logger) error {
} }
embyClient := emby.New(cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.UpstreamTimeout) embyClient := emby.New(cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.UpstreamTimeout)
retiredSessions, err := st.TrimSessionsToLimit(ctx, cfg.MaxClientsPerUser)
if err != nil {
return err
}
for _, sess := range retiredSessions {
_ = ca.Delete(ctx, cache.SessionKey(hex.EncodeToString(sess.TokenHash)))
if err := embyClient.Logout(ctx, emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
}); err != nil {
log.Warn("could not retire excess emby session",
"username", sess.Username,
"device", sess.DeviceName,
"error", err,
)
}
}
if len(retiredSessions) > 0 {
log.Info("enforced per-user device allowance",
"retired_sessions", len(retiredSessions),
"max_clients_per_user", cfg.MaxClientsPerUser,
)
}
var sonarrClient *sonarr.Client
if cfg.SonarrURL != "" {
sonarrClient = sonarr.New(cfg.SonarrURL, cfg.SonarrAPIKey, cfg.UpstreamTimeout)
log.Info("sonarr integration enabled", "url", cfg.SonarrURL)
}
recommender := recommend.NewEngine(embyClient, log) recommender := recommend.NewEngine(embyClient, log)
// Candidates come from the imported library when one exists, which keeps the // Candidates come from the imported library when one exists, which keeps the
@@ -99,6 +131,7 @@ func run(log *slog.Logger) error {
Store: st, Store: st,
Cache: ca, Cache: ca,
Recommender: recommender, Recommender: recommender,
Sonarr: sonarrClient,
Syncer: syncer, Syncer: syncer,
Log: log, Log: log,
}) })
@@ -135,7 +168,11 @@ func run(log *slog.Logger) error {
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
log.Info("listening", "addr", cfg.ListenAddr, "emby", cfg.EmbyURL) log.Info("server ready",
"listen", cfg.ListenAddr,
"emby", cfg.EmbyURL,
"sync_every", cfg.SyncInterval,
)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err errCh <- err
} }
+1
View File
@@ -29,6 +29,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync)) mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance)) mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy)) mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
return mux return mux
} }
+22
View File
@@ -97,6 +97,28 @@ func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
} }
} }
func TestServiceStatusReportsMaintenanceOutsideTheGate(t *testing.T) {
server := testServer(config.Config{})
server.maintenance.set(store.Maintenance{Enabled: true, Message: "Back after dinner"})
rec := httptest.NewRecorder()
server.handleServiceStatus(
rec,
httptest.NewRequest(http.MethodGet, "/v1/status", nil),
store.Session{},
)
if rec.Code != http.StatusOK {
t.Fatalf("status endpoint returned %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["maintenance"] != true || body["message"] != "Back after dinner" {
t.Fatalf("unexpected status response: %v", body)
}
}
func TestAdminIsDisabledWithoutAToken(t *testing.T) { func TestAdminIsDisabledWithoutAToken(t *testing.T) {
server := testServer(config.Config{}) server := testServer(config.Config{})
+41 -3
View File
@@ -18,12 +18,14 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config" "github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/store"
) )
@@ -33,8 +35,10 @@ type Server struct {
store *store.Store store *store.Store
cache *cache.Cache cache *cache.Cache
recommender *recommend.Engine recommender *recommend.Engine
sonarr *sonarr.Client
syncer syncerHandle syncer syncerHandle
log *slog.Logger log *slog.Logger
sonarrMu sync.Mutex
recommendationBuilds recommendationBuilds recommendationBuilds recommendationBuilds
maintenance maintenanceState maintenance maintenanceState
@@ -48,17 +52,22 @@ type Deps struct {
Store *store.Store Store *store.Store
Cache *cache.Cache Cache *cache.Cache
Recommender *recommend.Engine Recommender *recommend.Engine
Sonarr *sonarr.Client
Syncer syncerHandle Syncer syncerHandle
Log *slog.Logger Log *slog.Logger
} }
func New(cfg config.Config, deps Deps) *Server { func New(cfg config.Config, deps Deps) *Server {
if cfg.MaxClientsPerUser < 1 {
cfg.MaxClientsPerUser = 1
}
return &Server{ return &Server{
cfg: cfg, cfg: cfg,
emby: deps.Emby, emby: deps.Emby,
store: deps.Store, store: deps.Store,
cache: deps.Cache, cache: deps.Cache,
recommender: deps.Recommender, recommender: deps.Recommender,
sonarr: deps.Sonarr,
syncer: deps.Syncer, syncer: deps.Syncer,
log: deps.Log, log: deps.Log,
} }
@@ -70,6 +79,7 @@ func (s *Server) Routes() http.Handler {
v1 := http.NewServeMux() v1 := http.NewServeMux()
v1.HandleFunc("POST /v1/auth/login", s.handleLogin) v1.HandleFunc("POST /v1/auth/login", s.handleLogin)
v1.HandleFunc("GET /v1/auth/policy", s.handleAuthPolicy)
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))
@@ -93,8 +103,12 @@ func (s *Server) Routes() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth) mux.HandleFunc("GET /healthz", s.handleHealth)
mux.HandleFunc("GET /readyz", s.handleReady) mux.HandleFunc("GET /readyz", s.handleReady)
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
// status even while every normal /v1 operation is deliberately unavailable.
mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus))
mux.Handle("/v1/", s.maintenanceGate(v1)) mux.Handle("/v1/", s.maintenanceGate(v1))
mux.Handle("/admin/", s.adminRoutes()) mux.Handle("/admin/", s.adminRoutes())
mux.HandleFunc("GET /updates/{filename}", s.handleReleaseDownload)
return s.withLogging(mux) return s.withLogging(mux)
} }
@@ -134,15 +148,33 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r) next.ServeHTTP(rec, r)
// Path only: query strings can carry image tokens. // Path only: query strings can carry image tokens.
s.log.Info("request", level := requestLogLevel(r.URL.Path, rec.status)
s.log.Log(r.Context(), level, "HTTP request",
"method", r.Method, "method", r.Method,
"path", r.URL.Path, "path", r.URL.Path,
"status", rec.status, "status", rec.status,
"ms", time.Since(start).Milliseconds(), "duration", time.Since(start).Round(time.Millisecond),
) )
}) })
} }
// Successful high-frequency probes and artwork fetches stay available at DEBUG without
// overwhelming the normal Docker log. Failures are always promoted so they remain
// visible regardless of path.
func requestLogLevel(path string, status int) slog.Level {
switch {
case status >= http.StatusInternalServerError:
return slog.LevelError
case status >= http.StatusBadRequest:
return slog.LevelWarn
case path == "/healthz", path == "/readyz", path == "/v1/status",
strings.HasPrefix(path, "/v1/images/"):
return slog.LevelDebug
default:
return slog.LevelInfo
}
}
type statusRecorder struct { type statusRecorder struct {
http.ResponseWriter http.ResponseWriter
status int status int
@@ -184,6 +216,7 @@ type cachedSession struct {
Username string `json:"n"` Username string `json:"n"`
ServerID string `json:"s"` ServerID string `json:"s"`
DeviceID string `json:"d"` DeviceID string `json:"d"`
DeviceName string `json:"dn,omitempty"`
} }
// sessionFor resolves a token, using Redis to keep the hot path off Postgres. // sessionFor resolves a token, using Redis to keep the hot path off Postgres.
@@ -201,6 +234,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
Username: cs.Username, Username: cs.Username,
ServerID: cs.ServerID, ServerID: cs.ServerID,
DeviceID: cs.DeviceID, DeviceID: cs.DeviceID,
DeviceName: cs.DeviceName,
}, nil }, nil
} }
} }
@@ -220,6 +254,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
Username: sess.Username, Username: sess.Username,
ServerID: sess.ServerID, ServerID: sess.ServerID,
DeviceID: sess.DeviceID, DeviceID: sess.DeviceID,
DeviceName: sess.DeviceName,
}); err == nil { }); err == nil {
_ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL) _ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL)
} }
@@ -231,7 +266,10 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
} }
func credentials(sess store.Session) emby.Credentials { func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{UserID: sess.EmbyUserID, Token: sess.EmbyToken, DeviceID: sess.DeviceID} return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
}
} }
// --- responses -------------------------------------------------------------- // --- responses --------------------------------------------------------------
+59
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/emby"
) )
@@ -57,6 +58,64 @@ func TestNewTokenIsUnique(t *testing.T) {
} }
} }
func TestAuthPolicyPublishesConfiguredDeviceAllowance(t *testing.T) {
s := &Server{cfg: config.Config{MaxClientsPerUser: 4}}
req := httptest.NewRequest(http.MethodGet, "/v1/auth/policy", nil)
rec := httptest.NewRecorder()
s.handleAuthPolicy(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var policy authPolicyResponse
if err := json.Unmarshal(rec.Body.Bytes(), &policy); err != nil {
t.Fatalf("decode policy: %v", err)
}
if policy.MaxClientsPerUser != 4 {
t.Fatalf("max clients = %d, want 4", policy.MaxClientsPerUser)
}
}
func TestSonarrScheduleRequiresCapableClient(t *testing.T) {
tests := map[string]bool{
"": false,
"0.1.53": false,
"0.1.54": true,
"0.2.0": true,
}
for version, want := range tests {
req := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
req.Header.Set("X-Memby-Version", version)
if got := supportsSonarrSchedule(req); got != want {
t.Errorf("supportsSonarrSchedule(%q) = %v, want %v", version, got, want)
}
}
}
func TestPlaybackHintAvoidsAnUpstreamItemLookup(t *testing.T) {
req := httptest.NewRequest(
http.MethodGet,
"/v1/items/42/playback?type=Movie&title=Arrival&resumePositionMs=12000",
nil,
)
item, ok := playbackHint(req, "42")
if !ok {
t.Fatal("valid hint was rejected")
}
if item.ID != "42" || item.Type != "Movie" || item.Name != "Arrival" {
t.Fatalf("unexpected hinted item: %+v", item)
}
if item.UserData.PlaybackPositionTicks != 12_000*ticksPerMillisecond {
t.Fatalf("resume ticks = %d", item.UserData.PlaybackPositionTicks)
}
bad := httptest.NewRequest(http.MethodGet, "/v1/items/42/playback?type=Playlist", nil)
if _, ok := playbackHint(bad, "42"); ok {
t.Fatal("unsupported type should fall back to Emby")
}
}
// Empty rows must serialise as [] so kotlinx.serialization can decode them into the // Empty rows must serialise as [] so kotlinx.serialization can decode them into the
// client's non-null List fields. // client's non-null List fields.
func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) { func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) {
+78 -16
View File
@@ -2,24 +2,39 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"strings" "strings"
"github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/store"
) )
type loginRequest struct { type loginRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
DeviceID string `json:"deviceId"` DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
} }
type loginResponse struct { type loginResponse struct {
Token string `json:"token"` Token string `json:"token"`
UserID string `json:"userId"` UserID string `json:"userId"`
Username string `json:"username"` Username string `json:"username"`
ServerID string `json:"serverId"` ServerID string `json:"serverId"`
ActiveClients int `json:"activeClients,omitempty"`
MaxClientsPerUser int `json:"maxClientsPerUser"`
}
type authPolicyResponse struct {
MaxClientsPerUser int `json:"maxClientsPerUser"`
}
func (s *Server) handleAuthPolicy(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, authPolicyResponse{
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
})
} }
// handleLogin exchanges Emby credentials for a gateway token. // handleLogin exchanges Emby credentials for a gateway token.
@@ -40,8 +55,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if req.DeviceID == "" { if req.DeviceID == "" {
req.DeviceID = "memby-tv" req.DeviceID = "memby-tv"
} }
req.DeviceName = strings.TrimSpace(req.DeviceName)
if req.DeviceName == "" {
// Compatibility for APKs released before device naming. New clients require an
// editable name in their UI, but an older TV must still be able to sign in while
// the household rollout is in progress.
req.DeviceName = "Memby TV"
}
if len([]rune(req.DeviceName)) > 80 {
writeError(w, http.StatusBadRequest, "device name is too long")
return
}
auth, err := s.emby.Authenticate(r.Context(), req.Username, req.Password, req.DeviceID) auth, err := s.emby.Authenticate(
r.Context(), req.Username, req.Password, req.DeviceID, req.DeviceName,
)
if err != nil { if err != nil {
// Never echo Emby's body here: a failed sign-in is the one place a wrong // Never echo Emby's body here: a failed sign-in is the one place a wrong
// password could be reflected back. // password could be reflected back.
@@ -64,21 +92,54 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
Username: auth.User.Name, Username: auth.User.Name,
ServerID: auth.ServerID, ServerID: auth.ServerID,
DeviceID: req.DeviceID, DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
} }
if sess.Username == "" { if sess.Username == "" {
sess.Username = req.Username sess.Username = req.Username
} }
if err := s.store.CreateSession(r.Context(), sess); err != nil { replacedHash, activeClients, err := s.store.CreateSession(
r.Context(), sess, s.cfg.MaxClientsPerUser,
)
if errors.Is(err, store.ErrDeviceLimit) {
if revokeErr := s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: req.DeviceID, DeviceName: req.DeviceName,
}); revokeErr != nil {
s.log.Warn("could not retire refused emby session", "error", revokeErr)
}
s.log.Warn("device allowance reached",
"username", sess.Username,
"active_clients", activeClients,
"max_clients", s.cfg.MaxClientsPerUser,
)
writeJSON(w, http.StatusConflict, map[string]any{
"error": "device_limit_reached",
"message": "This account has reached its Memby device allowance.",
"activeClients": activeClients,
"maxClientsPerUser": s.cfg.MaxClientsPerUser,
})
return
}
if err != nil {
_ = s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: req.DeviceID, DeviceName: req.DeviceName,
})
s.log.Error("session persist failed", "error", err) s.log.Error("session persist failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start a session") writeError(w, http.StatusInternalServerError, "could not start a session")
return return
} }
if len(replacedHash) > 0 {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(replacedHash)))
}
writeJSON(w, http.StatusOK, loginResponse{ writeJSON(w, http.StatusOK, loginResponse{
Token: token, Token: token,
UserID: sess.EmbyUserID, UserID: sess.EmbyUserID,
Username: sess.Username, Username: sess.Username,
ServerID: sess.ServerID, ServerID: sess.ServerID,
ActiveClients: activeClients,
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
}) })
} }
@@ -94,8 +155,9 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store
// handleSession lets the TV confirm a stored token is still good before rendering. // handleSession lets the TV confirm a stored token is still good before rendering.
func (s *Server) handleSession(w http.ResponseWriter, _ *http.Request, sess store.Session) { func (s *Server) handleSession(w http.ResponseWriter, _ *http.Request, sess store.Session) {
writeJSON(w, http.StatusOK, loginResponse{ writeJSON(w, http.StatusOK, loginResponse{
UserID: sess.EmbyUserID, UserID: sess.EmbyUserID,
Username: sess.Username, Username: sess.Username,
ServerID: sess.ServerID, ServerID: sess.ServerID,
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
}) })
} }
+33 -5
View File
@@ -8,6 +8,7 @@ import (
"strconv" "strconv"
"sync" "sync"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/recommend"
@@ -63,10 +64,11 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
cred := credentials(sess) cred := credentials(sess)
var ( var (
mu sync.Mutex mu sync.Mutex
failures int failures int
out homeResponse out homeResponse
wg sync.WaitGroup sonarrRow *recommend.Row
wg sync.WaitGroup
) )
run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) { run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) {
@@ -119,6 +121,20 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
"Limit": {itoa(limit)}, "Limit": {itoa(limit)},
}, fieldsRow)) }, fieldsRow))
}) })
if s.sonarr != nil && supportsSonarrSchedule(r) {
wg.Add(1)
go func() {
defer wg.Done()
row, err := s.sonarrAiringTodayRow(ctx)
if err != nil {
s.log.Warn("sonarr calendar row failed", "error", err)
return
}
mu.Lock()
sonarrRow = row
mu.Unlock()
}()
}
wg.Wait() wg.Wait()
@@ -136,7 +152,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
if recommendations == nil { if recommendations == nil {
s.refreshRecommendationsInBackground(sess) s.refreshRecommendationsInBackground(sess)
} }
out.Rows = append(baseRows(out), recommendations...) rows := baseRows(out)
if sonarrRow != nil {
// The schedule is most useful beside Next Up, before personal collections.
rows = append(rows[:2], append([]recommend.Row{*sonarrRow}, rows[2:]...)...)
}
out.Rows = append(rows, recommendations...)
body, err := json.Marshal(out) body, err := json.Marshal(out)
if err != nil { if err != nil {
@@ -154,6 +175,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
writeRaw(w, http.StatusOK, body) writeRaw(w, http.StatusOK, body)
} }
// Older clients render unknown rows but do not understand MembyPlayable=false, so they
// could try to send a synthetic Sonarr id to Emby. The feature ships with 0.1.54.
func supportsSonarrSchedule(r *http.Request) bool {
version := clientVersion(r)
return version != "" && appupdate.CompareVersions(version, "0.1.54") >= 0
}
// handleScreensaver serves the backdrop pool. The pool is cached and shuffled per // handleScreensaver serves the backdrop pool. The pool is cached and shuffled per
// request, so the Dream still looks random without re-querying Emby every few seconds. // request, so the Dream still looks random without re-querying Emby every few seconds.
func (s *Server) handleScreensaver(w http.ResponseWriter, r *http.Request, sess store.Session) { func (s *Server) handleScreensaver(w http.ResponseWriter, r *http.Request, sess store.Session) {
+52
View File
@@ -1,11 +1,14 @@
package api package api
import ( import (
"errors"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/store"
) )
@@ -29,6 +32,10 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
writeError(w, http.StatusNotFound, "unknown image") writeError(w, http.StatusNotFound, "unknown image")
return return
} }
if strings.HasPrefix(itemID, "sonarr:") {
s.handleSonarrImage(w, r, itemID, imageType)
return
}
params := url.Values{} params := url.Values{}
for _, key := range []string{"tag", "maxWidth", "maxHeight", "quality"} { for _, key := range []string{"tag", "maxWidth", "maxHeight", "quality"} {
@@ -63,3 +70,48 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
s.log.Warn("image copy failed", "error", err) s.log.Warn("image copy failed", "error", err)
} }
} }
func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
if s.sonarr == nil {
writeError(w, http.StatusNotFound, "unknown image")
return
}
parts := strings.Split(itemID, ":")
if len(parts) != 3 {
writeError(w, http.StatusNotFound, "unknown image")
return
}
seriesID, err := strconv.Atoi(parts[1])
if err != nil || seriesID <= 0 {
writeError(w, http.StatusNotFound, "unknown image")
return
}
coverType := map[string]string{"Primary": "poster", "Backdrop": "fanart"}[imageType]
if coverType == "" {
writeError(w, http.StatusNotFound, "unknown image")
return
}
resp, err := s.sonarr.MediaCover(r.Context(), seriesID, coverType)
if err != nil {
var apiErr *sonarr.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
writeError(w, http.StatusNotFound, "image not found")
return
}
s.log.Warn("sonarr image failed", "series_id", seriesID, "type", coverType, "error", err)
writeError(w, http.StatusBadGateway, "could not load the image")
return
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
if cl := resp.Header.Get("Content-Length"); cl != "" {
w.Header().Set("Content-Length", cl)
}
w.Header().Set("Cache-Control", "private, max-age=3600")
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, resp.Body); err != nil {
s.log.Warn("sonarr image copy failed", "error", err)
}
}
+15
View File
@@ -85,3 +85,18 @@ func (s *Server) maintenanceGate(next http.Handler) http.Handler {
}) })
}) })
} }
// handleServiceStatus is the live control channel clients poll while the app is open.
// It sits outside maintenanceGate so maintenance can interrupt playback rather than only
// being discovered the next time a content request happens to run.
func (s *Server) handleServiceStatus(w http.ResponseWriter, _ *http.Request, _ store.Session) {
state := s.maintenance.get()
message := state.Message
if state.Enabled && message == "" {
message = store.DefaultMaintenanceMessage
}
writeJSON(w, http.StatusOK, map[string]any{
"maintenance": state.Enabled,
"message": message,
})
}
+38 -9
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/emby"
@@ -40,15 +41,18 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
} }
cred := credentials(sess) cred := credentials(sess)
raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName") item, hinted := playbackHint(r, itemID)
if err != nil { if !hinted {
s.writeUpstreamError(w, err, "could not load the item") raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName")
return if err != nil {
} s.writeUpstreamError(w, err, "could not load the item")
item, err := emby.Summarise(raw) return
if err != nil { }
writeError(w, http.StatusBadGateway, "unreadable item from emby") item, err = emby.Summarise(raw)
return if err != nil {
writeError(w, http.StatusBadGateway, "unreadable item from emby")
return
}
} }
target := item target := item
@@ -78,6 +82,31 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
}) })
} }
// Current clients already know the selected item's type, title and cached resume point.
// Accepting those as hints removes one serial Emby request from every launch. Older
// clients omit them and retain the authoritative lookup above.
func playbackHint(r *http.Request, itemID string) (emby.Summary, bool) {
itemType := strings.TrimSpace(r.URL.Query().Get("type"))
switch {
case strings.EqualFold(itemType, "Movie"):
itemType = "Movie"
case strings.EqualFold(itemType, "Episode"):
itemType = "Episode"
case strings.EqualFold(itemType, "Series"):
itemType = "Series"
default:
return emby.Summary{}, false
}
resumeMs, _ := strconv.ParseInt(r.URL.Query().Get("resumePositionMs"), 10, 64)
item := emby.Summary{
ID: itemID,
Name: strings.TrimSpace(r.URL.Query().Get("title")),
Type: itemType,
}
item.UserData.PlaybackPositionTicks = max64(resumeMs, 0) * ticksPerMillisecond
return item, true
}
// firstPlayableEpisode prefers the server's next-up choice and falls back to episode one. // firstPlayableEpisode prefers the server's next-up choice and falls back to episode one.
func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials, seriesID string) (*emby.Summary, error) { func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials, seriesID string) (*emby.Summary, error) {
nextUp, err := s.emby.NextUp(ctx, cred, url.Values{ nextUp, err := s.emby.NextUp(ctx, cred, url.Values{
+4
View File
@@ -79,6 +79,10 @@ func Decide(policy Policy, clientVersion string) Decision {
return none return none
} }
// CompareVersions returns -1, 0, or 1 and is shared by policy decisions and the release
// publisher's downgrade guard.
func CompareVersions(a, b string) int { return compare(parseVersion(a), parseVersion(b)) }
// compare returns -1, 0 or 1. Missing components count as zero, so 0.1 == 0.1.0. // compare returns -1, 0 or 1. Missing components count as zero, so 0.1 == 0.1.0.
func compare(a, b []int) int { func compare(a, b []int) int {
for i := 0; i < len(a) || i < len(b); i++ { for i := 0; i < len(a) || i < len(b); i++ {
+1 -1
View File
@@ -87,7 +87,7 @@ func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID,
// Recommendations cost several Emby queries to build, so they must survive the cache // Recommendations cost several Emby queries to build, so they must survive the cache
// wipe that every favourite toggle triggers. Only a genuine change in viewing history // wipe that every favourite toggle triggers. Only a genuine change in viewing history
// — a finished playback — retires them, via [Cache.InvalidateRecommendations]. // — a finished playback — retires them, via [Cache.InvalidateRecommendations].
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows", userID) } func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v2", userID) }
func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error { func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error {
return c.Delete(ctx, RecommendationsKey(userID)) return c.Delete(ctx, RecommendationsKey(userID))
+54 -1
View File
@@ -7,6 +7,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
_ "time/tzdata"
) )
type Config struct { type Config struct {
@@ -33,6 +34,8 @@ type Config struct {
SessionTTL time.Duration SessionTTL time.Duration
// SessionIdleExpiry retires gateway tokens that go unused for this long. // SessionIdleExpiry retires gateway tokens that go unused for this long.
SessionIdleExpiry time.Duration SessionIdleExpiry time.Duration
// MaxClientsPerUser is enforced transactionally when a new TV signs in.
MaxClientsPerUser int
// RecommendTTL is how long computed recommendation rows stay warm. Long, because // RecommendTTL is how long computed recommendation rows stay warm. Long, because
// taste moves slowly and each rebuild costs several Emby queries. // taste moves slowly and each rebuild costs several Emby queries.
@@ -47,6 +50,14 @@ type Config struct {
// unconfigured deployment cannot leave it exposed. // unconfigured deployment cannot leave it exposed.
AdminToken string AdminToken string
// PublicURL is the externally reachable Memby gateway address used in update links.
PublicURL string
// ReleaseDir persists signed APKs published by CI.
ReleaseDir string
// ReleasePublishToken authorizes the CI-only release upload endpoint. It is separate
// from AdminToken so a compromised build runner cannot change maintenance settings.
ReleasePublishToken string
// SyncInterval is how often the library import runs. Zero disables the schedule. // SyncInterval is how often the library import runs. Zero disables the schedule.
SyncInterval time.Duration SyncInterval time.Duration
// SyncTimeout bounds one import; a full pass over a large library is slow. // SyncTimeout bounds one import; a full pass over a large library is slow.
@@ -60,6 +71,13 @@ type Config struct {
// AnalyticsRetention is how long raw row events are kept before being pruned. // AnalyticsRetention is how long raw row events are kept before being pruned.
AnalyticsRetention time.Duration AnalyticsRetention time.Duration
// Sonarr is optional. When configured, its local calendar supplies the informational
// "Shows airing today" home row. The API key never leaves this server.
SonarrURL string
SonarrAPIKey string
SonarrTTL time.Duration
SonarrLocation *time.Location
} }
func Load() (Config, error) { func Load() (Config, error) {
@@ -76,10 +94,16 @@ func Load() (Config, error) {
ScreensaverTTL: duration("MEMBY_SCREENSAVER_TTL", 10*time.Minute), ScreensaverTTL: duration("MEMBY_SCREENSAVER_TTL", 10*time.Minute),
SessionTTL: duration("MEMBY_SESSION_CACHE_TTL", 5*time.Minute), SessionTTL: duration("MEMBY_SESSION_CACHE_TTL", 5*time.Minute),
SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour), SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour),
MaxClientsPerUser: integer("MEMBY_MAX_CLIENTS_PER_USER", 1),
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 2*time.Hour), RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 2*time.Hour),
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second), RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")), AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"),
ReleasePublishToken: strings.TrimSpace(
os.Getenv("MEMBY_RELEASE_PUBLISH_TOKEN"),
),
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour), SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute), SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false), SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
@@ -87,6 +111,9 @@ func Load() (Config, error) {
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")), SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour), AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second), UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
} }
if c.EmbyURL == "" { if c.EmbyURL == "" {
@@ -95,9 +122,23 @@ func Load() (Config, error) {
if c.DatabaseURL == "" { if c.DatabaseURL == "" {
return c, fmt.Errorf("MEMBY_DATABASE_URL is required") return c, fmt.Errorf("MEMBY_DATABASE_URL is required")
} }
if c.MaxClientsPerUser < 1 {
return c, fmt.Errorf("MEMBY_MAX_CLIENTS_PER_USER must be at least 1")
}
if c.EmbyPublicURL == "" { if c.EmbyPublicURL == "" {
c.EmbyPublicURL = c.EmbyURL c.EmbyPublicURL = c.EmbyURL
} }
if c.ReleasePublishToken != "" && c.PublicURL == "" {
return c, fmt.Errorf("MEMBY_PUBLIC_URL is required when release publishing is enabled")
}
if (c.SonarrURL == "") != (c.SonarrAPIKey == "") {
return c, fmt.Errorf("MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY must be set together")
}
location, err := time.LoadLocation(env("MEMBY_TIMEZONE", "Pacific/Auckland"))
if err != nil {
return c, fmt.Errorf("MEMBY_TIMEZONE: %w", err)
}
c.SonarrLocation = location
return c, nil return c, nil
} }
@@ -134,3 +175,15 @@ func duration(key string, fallback time.Duration) time.Duration {
} }
return fallback return fallback
} }
func integer(key string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return value
}
+22 -7
View File
@@ -27,9 +27,10 @@ type Client struct {
// Credentials identify one signed-in Emby user. // Credentials identify one signed-in Emby user.
type Credentials struct { type Credentials struct {
UserID string UserID string
Token string Token string
DeviceID string DeviceID string
DeviceName string
} }
type ItemsResult struct { type ItemsResult struct {
@@ -82,13 +83,13 @@ func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
} }
} }
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID string) (*AuthResult, error) { func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName string) (*AuthResult, error) {
body, err := json.Marshal(map[string]string{"Username": username, "Pw": password}) body, err := json.Marshal(map[string]string{"Username": username, "Pw": password})
if err != nil { if err != nil {
return nil, err return nil, err
} }
req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil, req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil,
Credentials{DeviceID: deviceID}, bytes.NewReader(body)) Credentials{DeviceID: deviceID, DeviceName: deviceName}, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -104,6 +105,16 @@ func (c *Client) Authenticate(ctx context.Context, username, password, deviceID
return &out, nil return &out, nil
} }
// Logout retires a token created during authentication. The gateway uses this when a
// device is refused by policy so Emby is not left holding an orphaned session.
func (c *Client) Logout(ctx context.Context, cred Credentials) error {
req, err := c.newRequest(ctx, http.MethodPost, "/Sessions/Logout", nil, cred, nil)
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) Items(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) { func (c *Client) Items(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items", params) return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items", params)
} }
@@ -285,10 +296,14 @@ func (c *Client) newRequest(ctx context.Context, method, path string, params url
if deviceID == "" { if deviceID == "" {
deviceID = "memby-gateway" deviceID = "memby-gateway"
} }
deviceName := strings.TrimSpace(cred.DeviceName)
if deviceName == "" {
deviceName = "Memby TV"
}
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("X-Emby-Authorization", fmt.Sprintf( req.Header.Set("X-Emby-Authorization", fmt.Sprintf(
`MediaBrowser Client="%s", Device="Memby Gateway", DeviceId="%s", Version="1.0"`, `MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="1.0"`,
c.clientName, deviceID, c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID,
)) ))
if cred.Token != "" { if cred.Token != "" {
req.Header.Set("X-Emby-Token", cred.Token) req.Header.Set("X-Emby-Token", cred.Token)
+8 -1
View File
@@ -122,6 +122,7 @@ func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error)
return Result{}, err return Result{}, err
} }
s.log.Info("library sync started", "kind", kind, "trigger", trigger)
result, syncErr := s.run(ctx, cred, kind, since, startedAt) result, syncErr := s.run(ctx, cred, kind, since, startedAt)
result.Kind = kind result.Kind = kind
result.Duration = time.Since(startedAt) result.Duration = time.Since(startedAt)
@@ -147,7 +148,8 @@ func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error)
} }
s.log.Info("library sync finished", s.log.Info("library sync finished",
"kind", kind, "trigger", trigger, "seen", result.Seen, "kind", kind, "trigger", trigger, "seen", result.Seen,
"upserted", result.Upserted, "removed", result.Removed, "ms", result.DurationMs) "upserted", result.Upserted, "removed", result.Removed,
"duration", result.Duration.Round(time.Millisecond))
return result, nil return result, nil
} }
@@ -204,6 +206,11 @@ func (s *Syncer) run(
result.Seen += len(page.Items) result.Seen += len(page.Items)
result.Upserted += int(written) result.Upserted += int(written)
s.log.Info("library sync progress",
"kind", kind,
"seen", result.Seen,
"upserted", result.Upserted,
)
if len(page.Items) < pageSize { if len(page.Items) < pageSize {
break break
+110 -12
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/url" "net/url"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -33,6 +34,27 @@ type LibrarySource interface {
LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error) LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error)
} }
// CuratedLibrarySource is the optional richer catalogue query used for server-authored
// collections. Keeping it separate preserves compatibility with simpler test sources.
type CuratedLibrarySource interface {
CuratedCandidates(
ctx context.Context,
itemTypes, genres, studios []string,
limit int,
) ([]json.RawMessage, error)
}
// CuratedRow defines one reusable server-side shelf. Filtering determines membership;
// the user's profile determines both item order and shelf order.
type CuratedRow struct {
ID string
Title string
Kind string
ItemTypes []string
Genres []string
Studios []string
}
type Engine struct { type Engine struct {
source Source source Source
log *slog.Logger log *slog.Logger
@@ -47,6 +69,7 @@ type Engine struct {
// screen rather than a wall of near-duplicates. // screen rather than a wall of near-duplicates.
MaxSimilarRows int MaxSimilarRows int
RowSize int RowSize int
CuratedRows []CuratedRow
} }
func NewEngine(source Source, log *slog.Logger) *Engine { func NewEngine(source Source, log *slog.Logger) *Engine {
@@ -56,6 +79,29 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
MinRowItems: 4, MinRowItems: 4,
MaxSimilarRows: 2, MaxSimilarRows: 2,
RowSize: 20, RowSize: 20,
CuratedRows: []CuratedRow{
{
ID: "curated:apple-tv",
Title: "Apple TV+ Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Studios: []string{"Apple TV+", "Apple TV Plus", "Apple Studios"},
},
{
ID: "curated:drama-shows",
Title: "Drama TV Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Genres: []string{"Drama"},
},
{
ID: "curated:comedy-shows",
Title: "Comedy TV Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Genres: []string{"Comedy"},
},
},
} }
} }
@@ -76,25 +122,77 @@ func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, e
} }
profile := BuildProfile(history, favorites) profile := BuildProfile(history, favorites)
if profile.IsEmpty() {
// A brand-new user has nothing to recommend from. No rows is the honest answer.
return nil, nil
}
rows := make([]Row, 0, e.MaxSimilarRows+1) rows := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
for _, seed := range e.seedsFor(profile) { if !profile.IsEmpty() {
row, ok := e.similarRow(ctx, cred, profile, seed) for _, seed := range e.seedsFor(profile) {
if ok { row, ok := e.similarRow(ctx, cred, profile, seed)
if ok {
rows = append(rows, row)
}
}
if row, ok := e.historyRow(ctx, cred, profile); ok {
rows = append(rows, row) rows = append(rows, row)
} }
} }
rows = append(rows, e.buildCuratedRows(ctx, profile)...)
if row, ok := e.historyRow(ctx, cred, profile); ok {
rows = append(rows, row)
}
return rows, nil return rows, nil
} }
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
library, ok := e.Library.(CuratedLibrarySource)
if !ok {
return nil
}
type rankedDefinition struct {
definition CuratedRow
affinity float64
order int
}
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
for order, definition := range e.CuratedRows {
definitions = append(definitions, rankedDefinition{
definition: definition,
affinity: profile.CollectionAffinity(definition.Genres, definition.Studios),
order: order,
})
}
sort.SliceStable(definitions, func(i, j int) bool {
if definitions[i].affinity != definitions[j].affinity {
return definitions[i].affinity > definitions[j].affinity
}
return definitions[i].order < definitions[j].order
})
rows := make([]Row, 0, len(definitions))
for _, ranked := range definitions {
definition := ranked.definition
raws, err := library.CuratedCandidates(
ctx,
definition.ItemTypes,
definition.Genres,
definition.Studios,
e.RowSize*6,
)
if err != nil {
e.log.Warn("curated row failed", "row", definition.ID, "error", err)
continue
}
items := RankCollection(profile, Decode(raws), e.RowSize)
if len(items) < e.MinRowItems {
continue
}
rows = append(rows, Row{
ID: definition.ID,
Title: definition.Title,
Kind: definition.Kind,
Items: Raws(items),
})
}
return rows
}
// gatherSignals reads what the user has watched and favourited, in parallel. // gatherSignals reads what the user has watched and favourited, in parallel.
func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (history, favorites []Item, err error) { func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (history, favorites []Item, err error) {
var ( var (
+86
View File
@@ -27,6 +27,31 @@ type fakeSource struct {
similarSeeds []string similarSeeds []string
} }
type fakeCuratedLibrary struct {
byGenre map[string][]json.RawMessage
}
func (f *fakeCuratedLibrary) LibraryCandidates(
_ context.Context,
_ []string,
_ int,
) ([]json.RawMessage, error) {
return nil, nil
}
func (f *fakeCuratedLibrary) CuratedCandidates(
_ context.Context,
_ []string,
genres, studios []string,
_ int,
) ([]json.RawMessage, error) {
key := strings.Join(genres, "|")
if len(studios) > 0 {
key = "studio:" + studios[0]
}
return f.byGenre[key], nil
}
func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Values) (*emby.ItemsResult, error) { func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Values) (*emby.ItemsResult, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -191,6 +216,67 @@ func TestBuildRowsReturnsNothingForAUserWithNoHistory(t *testing.T) {
} }
} }
func TestCuratedShowRowsAndItemsAreOrderedByViewingAffinity(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {
raw("history", "Funny History", "Episode", "Comedy"),
},
},
similar: map[string][]json.RawMessage{},
}
engine := testEngine(source)
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Comedy": {
raw("comedy-low", "Lower Rated Match", "Series", "Comedy"),
raw("comedy-high", "Higher Rated Match", "Series", "Comedy"),
},
"Drama": {
raw("drama-1", "Drama One", "Series", "Drama"),
raw("drama-2", "Drama Two", "Series", "Drama"),
},
}}
engine.CuratedRows = []CuratedRow{
{ID: "drama", Title: "Drama TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}},
{ID: "comedy", Title: "Comedy TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}},
}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("expected two curated rows, got %v", rowTitles(rows))
}
if rows[0].ID != "comedy" || rows[1].ID != "drama" {
t.Fatalf("user's comedy affinity should order shelves, got %v", rowTitles(rows))
}
if rows[0].Kind != "shows" {
t.Fatalf("curated TV shelf kind = %q", rows[0].Kind)
}
}
func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
engine := testEngine(source)
engine.MinRowItems = 1
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Drama": {raw("drama", "Strong Drama", "Series", "Drama")},
}}
engine.CuratedRows = []CuratedRow{{
ID: "drama", Title: "Drama TV Shows", Kind: "shows",
ItemTypes: []string{"Series"}, Genres: []string{"Drama"},
}}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].ID != "drama" {
t.Fatalf("new profiles should receive quality-ranked curated rows: %+v", rows)
}
}
// A failing similarity lookup is one dead row, not a dead home screen. // A failing similarity lookup is one dead row, not a dead home screen.
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) { func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
source := &fakeSource{ source := &fakeSource{
+32 -1
View File
@@ -193,8 +193,39 @@ func (p Profile) Score(candidate Item) float64 {
return genreScore + studioScore + ratingScore return genreScore + studioScore + ratingScore
} }
// CollectionAffinity decides which curated shelf appears first for this user.
func (p Profile) CollectionAffinity(genres, studios []string) float64 {
var score float64
for _, genre := range genres {
score += weightFold(p.GenreWeights, genre)
}
for _, studio := range studios {
score += weightFold(p.StudioWeights, studio)
}
return score
}
func weightFold(weights map[string]float64, wanted string) float64 {
for key, value := range weights {
if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(wanted)) {
return value
}
}
return 0
}
// Rank scores, filters and truncates candidates, dropping duplicates by id. // Rank scores, filters and truncates candidates, dropping duplicates by id.
func Rank(profile Profile, candidates []Item, limit int) []Item { func Rank(profile Profile, candidates []Item, limit int) []Item {
return rank(profile, candidates, limit, false)
}
// RankCollection keeps unseen candidates with no affinity/rating score at the end,
// ensuring a curated shelf remains useful for a new profile or unrated library.
func RankCollection(profile Profile, candidates []Item, limit int) []Item {
return rank(profile, candidates, limit, true)
}
func rank(profile Profile, candidates []Item, limit int, includeZero bool) []Item {
type scored struct { type scored struct {
item Item item Item
score float64 score float64
@@ -207,7 +238,7 @@ func Rank(profile Profile, candidates []Item, limit int) []Item {
continue continue
} }
seen[candidate.ID] = true seen[candidate.ID] = true
if score := profile.Score(candidate); score > 0 { if score := profile.Score(candidate); score > 0 || includeZero && score == 0 {
ranked = append(ranked, scored{candidate, score}) ranked = append(ranked, scored{candidate, score})
} }
} }
+10
View File
@@ -153,6 +153,16 @@ func TestRankRespectsLimit(t *testing.T) {
} }
} }
func TestCollectionAffinityIsCaseInsensitive(t *testing.T) {
profile := Profile{
GenreWeights: map[string]float64{"Comedy": 2},
StudioWeights: map[string]float64{"Apple TV+": 0.5},
}
if got := profile.CollectionAffinity([]string{"comedy"}, []string{"apple tv+"}); got != 2.5 {
t.Fatalf("CollectionAffinity = %v, want 2.5", got)
}
}
func TestDecodeKeepsRawPayload(t *testing.T) { func TestDecodeKeepsRawPayload(t *testing.T) {
raw := json.RawMessage(`{"Id":"1","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"ImageTags":{"Primary":"abc"}}`) raw := json.RawMessage(`{"Id":"1","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"ImageTags":{"Primary":"abc"}}`)
items := Decode([]json.RawMessage{raw, json.RawMessage(`{"broken":`), json.RawMessage(`{"Name":"no id"}`)}) items := Decode([]json.RawMessage{raw, json.RawMessage(`{"broken":`), json.RawMessage(`{"Name":"no id"}`)})
+45
View File
@@ -138,6 +138,51 @@ func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit in
return collectPayloads(rows) return collectPayloads(rows)
} }
// CuratedCandidates filters the imported catalogue for a server-authored shelf. Arrays
// are matched case-insensitively because Emby studio capitalisation is not consistent.
func (s *Store) CuratedCandidates(
ctx context.Context,
itemTypes, genres, studios []string,
limit int,
) ([]json.RawMessage, error) {
if len(itemTypes) == 0 || (len(genres) == 0 && len(studios) == 0) {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT payload
FROM library_items
WHERE type = ANY($1)
AND (
cardinality($2::text[]) = 0 OR
EXISTS (
SELECT 1 FROM unnest(genres) AS genre
WHERE lower(genre) = ANY($2)
)
)
AND (
cardinality($3::text[]) = 0 OR
EXISTS (
SELECT 1 FROM unnest(studios) AS studio
WHERE lower(studio) = ANY($3)
)
)
ORDER BY community_rating DESC NULLS LAST, date_created DESC NULLS LAST
LIMIT $4`,
itemTypes, lowerStrings(genres), lowerStrings(studios), limit)
if err != nil {
return nil, fmt.Errorf("store: curated candidates: %w", err)
}
return collectPayloads(rows)
}
func lowerStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
out = append(out, strings.ToLower(strings.TrimSpace(value)))
}
return out
}
func (s *Store) LibraryStats(ctx context.Context) (LibraryStats, error) { func (s *Store) LibraryStats(ctx context.Context) (LibraryStats, error) {
stats := LibraryStats{ByType: map[string]int64{}} stats := LibraryStats{ByType: map[string]int64{}}
+16
View File
@@ -10,12 +10,28 @@ CREATE TABLE IF NOT EXISTS sessions (
username TEXT NOT NULL, username TEXT NOT NULL,
server_id TEXT NOT NULL DEFAULT '', server_id TEXT NOT NULL DEFAULT '',
device_id TEXT NOT NULL DEFAULT '', device_id TEXT NOT NULL DEFAULT '',
device_name TEXT NOT NULL DEFAULT 'Memby TV',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now() last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS device_name TEXT NOT NULL DEFAULT 'Memby TV';
-- Older builds could create more than one token for the same physical TV. Keep the most
-- recently used row before adding the identity constraint.
DELETE FROM sessions older
USING sessions newer
WHERE older.emby_user_id = newer.emby_user_id
AND older.device_id = newer.device_id
AND (
older.last_seen_at < newer.last_seen_at
OR (older.last_seen_at = newer.last_seen_at AND older.token_hash < newer.token_hash)
);
CREATE INDEX IF NOT EXISTS sessions_emby_user_idx ON sessions (emby_user_id); CREATE INDEX IF NOT EXISTS sessions_emby_user_idx ON sessions (emby_user_id);
CREATE INDEX IF NOT EXISTS sessions_last_seen_idx ON sessions (last_seen_at); CREATE INDEX IF NOT EXISTS sessions_last_seen_idx ON sessions (last_seen_at);
CREATE UNIQUE INDEX IF NOT EXISTS sessions_user_device_idx
ON sessions (emby_user_id, device_id);
-- The imported library. -- The imported library.
-- --
+104 -11
View File
@@ -17,6 +17,7 @@ var schema string
// ErrNotFound is returned when a token does not match a live session. // ErrNotFound is returned when a token does not match a live session.
var ErrNotFound = errors.New("store: session not found") var ErrNotFound = errors.New("store: session not found")
var ErrDeviceLimit = errors.New("store: device limit reached")
type Session struct { type Session struct {
TokenHash []byte TokenHash []byte
@@ -25,6 +26,7 @@ type Session struct {
Username string Username string
ServerID string ServerID string
DeviceID string DeviceID string
DeviceName string
LastSeenAt time.Time LastSeenAt time.Time
} }
@@ -56,30 +58,73 @@ func (s *Store) Migrate(ctx context.Context) error {
return nil return nil
} }
func (s *Store) CreateSession(ctx context.Context, sess Session) error { // CreateSession enforces a user's device allowance under a per-user transaction lock.
_, err := s.pool.Exec(ctx, ` // Re-authenticating the same stable device replaces its token and never consumes a slot.
INSERT INTO sessions (token_hash, emby_user_id, emby_token, username, server_id, device_id) // The replaced hash is returned so its Redis entry can be invalidated immediately.
VALUES ($1, $2, $3, $4, $5, $6) func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int) ([]byte, int, error) {
ON CONFLICT (token_hash) DO UPDATE SET tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, 0, fmt.Errorf("store: begin session: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, sess.EmbyUserID); err != nil {
return nil, 0, fmt.Errorf("store: lock user sessions: %w", err)
}
var previousHash []byte
err = tx.QueryRow(ctx, `
SELECT token_hash FROM sessions
WHERE emby_user_id = $1 AND device_id = $2`,
sess.EmbyUserID, sess.DeviceID).Scan(&previousHash)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, fmt.Errorf("store: find device session: %w", err)
}
existingDevice := err == nil
var activeClients int
if err := tx.QueryRow(ctx,
`SELECT count(*) FROM sessions WHERE emby_user_id = $1`,
sess.EmbyUserID).Scan(&activeClients); err != nil {
return nil, 0, fmt.Errorf("store: count user sessions: %w", err)
}
if !existingDevice && activeClients >= maxClients {
return nil, activeClients, ErrDeviceLimit
}
_, err = tx.Exec(ctx, `
INSERT INTO sessions (
token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (emby_user_id, device_id) DO UPDATE SET
token_hash = EXCLUDED.token_hash,
emby_token = EXCLUDED.emby_token, emby_token = EXCLUDED.emby_token,
username = EXCLUDED.username, username = EXCLUDED.username,
server_id = EXCLUDED.server_id, server_id = EXCLUDED.server_id,
device_id = EXCLUDED.device_id, device_name = EXCLUDED.device_name,
last_seen_at = now()`, last_seen_at = now()`,
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username, sess.ServerID, sess.DeviceID) sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username,
sess.ServerID, sess.DeviceID, sess.DeviceName)
if err != nil { if err != nil {
return fmt.Errorf("store: create session: %w", err) return nil, 0, fmt.Errorf("store: create session: %w", err)
} }
return nil if !existingDevice {
activeClients++
}
if err := tx.Commit(ctx); err != nil {
return nil, 0, fmt.Errorf("store: commit session: %w", err)
}
return previousHash, activeClients, nil
} }
func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) { func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) {
var sess Session var sess Session
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, last_seen_at SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name, last_seen_at
FROM sessions WHERE token_hash = $1`, hash). FROM sessions WHERE token_hash = $1`, hash).
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username, Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.LastSeenAt) &sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound return Session{}, ErrNotFound
} }
@@ -111,3 +156,51 @@ func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int
} }
return tag.RowsAffected(), nil return tag.RowsAffected(), nil
} }
// TrimSessionsToLimit brings data created under an older, more generous policy back
// within the current allowance. The most recently active devices survive.
func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Session, error) {
rows, err := s.pool.Query(ctx, `
WITH ranked AS (
SELECT token_hash,
row_number() OVER (
PARTITION BY emby_user_id
ORDER BY last_seen_at DESC, created_at DESC, token_hash DESC
) AS device_rank
FROM sessions
),
retired AS (
DELETE FROM sessions current
USING ranked
WHERE current.token_hash = ranked.token_hash
AND ranked.device_rank > $1
RETURNING current.token_hash, current.emby_user_id, current.emby_token,
current.username, current.server_id, current.device_id,
current.device_name, current.last_seen_at
)
SELECT token_hash, emby_user_id, emby_token, username, server_id,
device_id, device_name, last_seen_at
FROM retired`,
maxClients,
)
if err != nil {
return nil, fmt.Errorf("store: trim sessions: %w", err)
}
defer rows.Close()
var retired []Session
for rows.Next() {
var sess Session
if err := rows.Scan(
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt,
); err != nil {
return nil, fmt.Errorf("store: scan trimmed session: %w", err)
}
retired = append(retired, sess)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: trim sessions rows: %w", err)
}
return retired, nil
}