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.
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.
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
# switch, and row analytics. Leave blank to disable /admin entirely. Generate with
# openssl rand -hex 32
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.
MEMBY_SYNC_INTERVAL=1h
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.
#MEMBY_SYNC_USER_ID=
#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.
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. */
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
@@ -37,11 +49,10 @@ android {
applicationId = "com.ponzischeme89.memby"
minSdk = 23
targetSdk = 35
// versionCode is derived from versionName: major*10000 + minor*100 + patch.
// 0.1.53 -> 153. Keep them in step; the in-app updater compares versionName,
// but Android will not install an APK whose versionCode went backwards.
versionCode = 153
versionName = "0.1.53"
// Derived from one version string so CI cannot publish a versionName/versionCode
// pair that Android later refuses to install.
versionCode = membyVersionCode
versionName = membyVersionName
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby
import android.content.Context
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.SettingsStore
/**
@@ -14,10 +15,13 @@ object ServiceLocator {
private set
lateinit var repository: EmbyRepository
private set
lateinit var maintenance: MaintenanceMonitor
private set
fun init(context: Context) {
if (::repository.isInitialized) return
settings = SettingsStore(context.applicationContext)
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.BaseItem
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.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayRowEvent
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.HomeRow
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.GatewayServiceFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import retrofit2.HttpException
@@ -47,6 +55,7 @@ data class Playable(
val title: String,
val url: String,
val resumePositionMs: Long = 0L,
val logoUrl: String? = null,
)
class EmbyRepository(private val settings: SettingsStore) {
@@ -54,15 +63,20 @@ class EmbyRepository(private val settings: SettingsStore) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile
private var snapshot: Settings = Settings.EMPTY
private var observedSettings: Settings = Settings.EMPTY
private val snapshot: Settings
get() = settings.current ?: observedSettings
init {
scope.launch { settings.settingsFlow.collect { snapshot = it } }
scope.launch { settings.settingsFlow.collect { observedSettings = it } }
}
val settingsFlow: Flow<Settings> get() = settings.settingsFlow
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
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)
@@ -119,20 +133,29 @@ class EmbyRepository(private val settings: SettingsStore) {
* Signs in. [serverUrl] is only consulted when the build does not hardwire one —
* 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()
snapshot = settings.snapshot() // pick up the freshly-generated device id
observedSettings = settings.snapshot() // pick up the freshly-generated device id
ServerConfig.gatewayUrl?.let { 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.
val result = requireGateway().login(
GatewayLoginRequest(
username = username,
password = password,
deviceId = snapshot.deviceId.ifEmpty { "memby" },
),
)
val result = try {
requireGateway().login(
GatewayLoginRequest(
username = username,
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()) {
"Gateway did not return a session"
}
@@ -143,7 +166,8 @@ class EmbyRepository(private val settings: SettingsStore) {
result.username.ifBlank { username },
result.serverId.takeIf { it.isNotBlank() },
)
snapshot = settings.snapshot()
settings.setDeviceName(deviceName)
observedSettings = settings.snapshot()
return
}
@@ -157,9 +181,13 @@ class EmbyRepository(private val settings: SettingsStore) {
"Server did not return an access token"
}
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() {
// 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.
@@ -167,16 +195,18 @@ class EmbyRepository(private val settings: SettingsStore) {
runCatching { requireGateway().logout() }
}
settings.clearSession()
snapshot = settings.snapshot()
observedSettings = settings.snapshot()
cachedApi = null
cachedBaseUrl = null
clearPlayableCache()
}
suspend fun switchProfile(profile: EmbyProfile) {
settings.switchProfile(profile)
snapshot = settings.snapshot()
observedSettings = settings.snapshot()
cachedApi = null
cachedBaseUrl = null
clearPlayableCache()
}
// --- Content -------------------------------------------------------------
@@ -343,6 +373,9 @@ class EmbyRepository(private val settings: SettingsStore) {
?.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. */
suspend fun getItemDetails(itemId: String): BaseItem {
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).
*/
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) {
// 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(
itemId = playback.itemId,
title = playback.title.ifBlank { item.name },
url = playback.url,
resumePositionMs = playback.resumePositionMs,
logoUrl = logoUrl(item),
)
}
if (item.isSeries) {
@@ -463,9 +549,29 @@ class EmbyRepository(private val settings: SettingsStore) {
append(item.name)
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) {
@@ -494,6 +600,7 @@ class EmbyRepository(private val settings: SettingsStore) {
requireApi().reportPlaybackStopped(playbackReport(itemId, positionMs, isPaused = true))
}
} finally {
clearPlayableCache()
_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 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 =
milliseconds.coerceAtLeast(0L) * 10_000L
private val BaseItem.resumePositionMs: Long
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
* 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.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
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.decodeFromString
import kotlinx.serialization.encodeToString
@@ -28,6 +35,7 @@ data class Settings(
val serverId: String? = null,
val username: String? = null,
val deviceId: String = "",
val deviceName: String = "",
val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS,
// Where "Check for updates" looks: a Gitea host + "owner/repo", plus an access
// token for the (private) repo's release API and asset downloads.
@@ -75,6 +83,12 @@ data class EmbyProfile(
)
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 {
val SERVER_URL = stringPreferencesKey("server_url")
@@ -83,6 +97,7 @@ class SettingsStore(private val context: Context) {
val SERVER_ID = stringPreferencesKey("server_id")
val USERNAME = stringPreferencesKey("username")
val DEVICE_ID = stringPreferencesKey("device_id")
val DEVICE_NAME = stringPreferencesKey("device_name")
val ROTATION_SECONDS = intPreferencesKey("rotation_interval_seconds")
val UPDATE_BASE_URL = stringPreferencesKey("update_base_url")
val UPDATE_REPO = stringPreferencesKey("update_repo")
@@ -97,39 +112,26 @@ class SettingsStore(private val context: Context) {
val PROFILES = stringPreferencesKey("profiles")
}
val settingsFlow: Flow<Settings> = context.dataStore.data.map { p ->
val storedProfiles = decodeProfiles(p[Keys.PROFILES])
val profiles = if (storedProfiles.isEmpty()) {
legacyProfile(p)?.let(::listOf).orEmpty()
} else {
storedProfiles
}
Settings(
serverUrl = p[Keys.SERVER_URL],
token = p[Keys.TOKEN],
userId = p[Keys.USER_ID],
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,
)
}
/**
* Start the single disk read as soon as the Application creates this store, then
* replay the decoded value to activities, repositories and services. Previously
* every collector repeated the cold mapping work and the home screen could be
* created before the repository had seen the saved cache.
*/
val settingsFlow: Flow<Settings> = context.dataStore.data
.map(::settingsFrom)
.distinctUntilChanged()
.onEach { latestSettings = it }
.shareIn(scope, started = SharingStarted.Eagerly, replay = 1)
suspend fun setRotationIntervalSeconds(seconds: Int) {
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. */
suspend fun setUpdateConfig(baseUrl: String, repo: String, token: String) {
context.dataStore.edit {
@@ -196,8 +198,12 @@ class SettingsStore(private val context: Context) {
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. */
suspend fun ensureDeviceId(): String {
@@ -284,6 +290,36 @@ class SettingsStore(private val context: Context) {
private fun decodeProfiles(value: String?): List<EmbyProfile> =
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> =
decodeProfiles(preferences[Keys.PROFILES]).ifEmpty {
legacyProfile(preferences)?.let(::listOf).orEmpty()
@@ -86,11 +86,22 @@ data class BaseItem(
@SerialName("ParentLogoItemId") val parentLogoItemId: String? = null,
@SerialName("ParentLogoImageTag") val parentLogoImageTag: String? = 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 isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true)
val isFavorite: Boolean get() = userData?.isFavorite == true
val isSonarrSchedule: Boolean get() = membySource == "sonarr"
/** Runtime in whole minutes, or null when unknown. */
val runtimeMinutes: Int?
@@ -15,6 +15,7 @@ data class GatewayLoginRequest(
val username: String,
val password: String,
val deviceId: String,
val deviceName: String,
)
@Serializable
@@ -23,6 +24,21 @@ data class GatewayLoginResponse(
val userId: String = "",
val username: 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`. */
@Serializable
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.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayItems
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.GatewayRowEvents
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.UserItemData
import retrofit2.http.Body
@@ -30,6 +32,9 @@ interface GatewayApi {
@POST("v1/auth/login")
suspend fun login(@Body body: GatewayLoginRequest): GatewayLoginResponse
@GET("v1/auth/policy")
suspend fun authPolicy(): GatewayAuthPolicy
@POST("v1/auth/logout")
suspend fun logout()
@@ -53,11 +58,20 @@ interface GatewayApi {
@GET("v1/update")
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}")
suspend fun item(@Path("id") itemId: String): BaseItem
@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")
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.zIndex
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.CalendarMonth
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Favorite
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.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.SkipNext
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.filled.Tv
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.tv.material3.Icon
import androidx.tv.material3.Button
import androidx.tv.material3.Text
@@ -120,6 +129,58 @@ data class HomeBrowseRow(
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
fun TvNavigationRail(
selected: BrowseDestination,
@@ -160,11 +221,27 @@ fun TvNavigationRail(
) {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Emby",
contentDescription = "Memby",
modifier = Modifier.width(32.dp).height(27.dp),
)
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))
@@ -528,6 +605,10 @@ fun MediaQuickActionsOverlay(
@Composable
private fun MetadataContent(item: BaseItem, sectionLabel: String) {
if (item.isSonarrSchedule) {
ScheduleMetadataContent(item, sectionLabel)
return
}
Column(
modifier = Modifier.fillMaxSize(),
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
private fun MetadataStatus(item: BaseItem) {
val position = item.userData?.playbackPositionTicks ?: 0L
@@ -656,6 +793,23 @@ fun MediaRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
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(
row.title,
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(
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
fun FocusScaleContainer(
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 {
showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}"
item.isSonarrSchedule -> item.membyAirLabel ?: "Airing today"
item.isEpisode -> item.seriesName ?: "Up next"
item.productionYear != null && item.runtimeMinutes != null ->
"${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 (item.userData?.played == true) append(", watched")
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 {
@@ -103,6 +103,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
init {
refreshAll()
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 {
repository.playbackStops.collect { refreshWatching() }
}
@@ -224,13 +232,26 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
val cached = synchronized(metadataCache) { metadataCache[item.id] }
_focusedItem.value = cached ?: item
metadataJob?.cancel()
if (cached != null) return
metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS)
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
coroutineScope {
// Resolution is tiny compared with video buffering and makes the later
// Play click a memory lookup. The repository single-flights requests,
// 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 {
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
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? =
state.watchingAndNextUp.firstOrNull()
@@ -1,8 +1,12 @@
package com.ponzischeme89.memby.ui
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.provider.Settings as AndroidSettings
import android.text.format.DateFormat
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
@@ -11,6 +15,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.key
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
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.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
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.data.Settings
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.ui.player.PlayerActivity
import com.ponzischeme89.memby.performance.PerformanceMonitor
@@ -102,6 +109,7 @@ import androidx.tv.material3.Button
import androidx.tv.material3.Card
import androidx.tv.material3.Text
import kotlinx.coroutines.launch
import java.util.Date
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -126,6 +134,8 @@ class MainActivity : ComponentActivity() {
private fun AppRoot(onCloseSettings: () -> Unit) {
val repo = ServiceLocator.repository
var settings by remember { mutableStateOf<Settings?>(null) }
var addingProfile by rememberSaveable { mutableStateOf(false) }
var startingFirstRun by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(repo) {
repo.settingsFlow.collect { settings = it }
}
@@ -134,155 +144,473 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
val loaded = settings
when {
loaded == null -> MembyLoadingScreen()
addingProfile -> SetupScreen(
onCancel = { addingProfile = false },
onSignedIn = { addingProfile = false },
)
loaded.isSignedIn -> {
BackHandler(onBack = onCloseSettings)
key(loaded.userId, loaded.serverUrl) {
HomeScreen(loaded)
HomeScreen(
settings = loaded,
onAddProfile = { addingProfile = true },
)
}
}
loaded.profiles.isNotEmpty() -> ProfileEntryScreen(loaded)
else -> SetupScreen()
loaded.profiles.isNotEmpty() -> ProfileEntryScreen(
settings = loaded,
onAddProfile = { addingProfile = true },
)
startingFirstRun -> SetupScreen(
onCancel = { startingFirstRun = false },
onSignedIn = { startingFirstRun = false },
)
else -> FirstRunScreen(onGetStarted = { startingFirstRun = true })
}
}
}
@Composable
private fun MembyLoadingScreen() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
val transition = rememberInfiniteTransition(label = "cold-start")
val pulse by transition.animateFloat(
initialValue = 0.96f,
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(
painter = androidx.compose.ui.res.painterResource(com.ponzischeme89.memby.R.drawable.emby_logo),
contentDescription = "Emby",
modifier = Modifier.width(92.dp).height(76.dp),
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
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)
Spacer(Modifier.height(10.dp))
Text("Preparing your library…", color = Color(0xFF9EA6AD), fontSize = 16.sp)
Row(
modifier = Modifier
.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
private fun SetupScreen(
onCancel: (() -> Unit)? = null,
onSignedIn: () -> Unit = {},
) {
val repo = ServiceLocator.repository
val context = LocalContext.current
val scope = rememberCoroutineScope()
// A build that hardwires the server (memby.serverUrl) never asks for an address.
val hardwiredHost = ServerConfig.displayHost
var serverUrl by rememberSaveable { mutableStateOf(if (hardwiredHost != null) "" else "http://") }
val suggestedName = remember(context) { suggestedDeviceName(context) }
var deviceName by rememberSaveable {
mutableStateOf(
ServiceLocator.settings.current?.deviceName
?.takeIf { it.isNotBlank() }
?: suggestedName,
)
}
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var connecting by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var maxClientsPerUser by remember { mutableStateOf<Int?>(null) }
val serverFocus = remember { FocusRequester() }
val usernameFocus = remember { FocusRequester() }
LaunchedEffect(hardwiredHost) {
runCatching { if (hardwiredHost != null) usernameFocus.requestFocus() else serverFocus.requestFocus() }
val deviceNameFocus = remember { FocusRequester() }
LaunchedEffect(repo) {
maxClientsPerUser = runCatching { repo.authPolicy()?.maxClientsPerUser }
.getOrNull()
?.takeIf { it > 0 }
}
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(100L)
runCatching { deviceNameFocus.requestFocus() }
}
if (onCancel != null) BackHandler(onBack = onCancel)
Column(
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 96.dp, vertical = 56.dp)
.width(720.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
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,
.background(
Brush.radialGradient(
colors = listOf(Color(0xFF172126), Color(0xFF090C0F)),
radius = 1_250f,
),
)
}
TvTextField(
label = "Username",
value = username,
onValueChange = { username = it; error = null },
modifier = Modifier.focusRequester(usernameFocus),
)
TvTextField(
label = "Password",
value = password,
onValueChange = { password = it; error = null },
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
}
},
.padding(horizontal = 72.dp, vertical = 44.dp),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.width(620.dp)
.clip(RoundedCornerShape(22.dp))
.background(Color(0xF2161C20))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(22.dp))
.padding(horizontal = 44.dp, vertical = 38.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text(if (connecting) "Connecting…" else "Connect")
}
if (onCancel != null) {
Button(onClick = onCancel) { Text("Cancel") }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
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
private fun ProfileEntryScreen(settings: Settings) {
private fun ProfileEntryScreen(
settings: Settings,
onAddProfile: () -> Unit,
) {
val repo = ServiceLocator.repository
val scope = rememberCoroutineScope()
var addingProfile by remember { mutableStateOf(false) }
var switchingProfileId by remember { mutableStateOf<String?>(null) }
if (addingProfile) {
SetupScreen(onCancel = { addingProfile = false })
} else {
ProfileChooser(
profiles = settings.profiles,
currentProfileId = null,
switchingProfileId = switchingProfileId,
onSelect = { profile ->
switchingProfileId = profile.id
scope.launch {
runCatching { repo.switchProfile(profile) }
.onFailure { switchingProfileId = null }
}
},
onAddProfile = { addingProfile = true },
onClose = null,
)
}
ProfileChooser(
profiles = settings.profiles,
currentProfileId = null,
switchingProfileId = switchingProfileId,
onSelect = { profile ->
switchingProfileId = profile.id
scope.launch {
runCatching { repo.switchProfile(profile) }
.onFailure { switchingProfileId = null }
}
},
onAddProfile = onAddProfile,
onClose = null,
)
}
@Composable
@@ -311,11 +639,11 @@ private fun ProfileChooser(
) {
androidx.compose.foundation.Image(
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),
)
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(
if (switchingProfileId == null) "Choose a profile to continue" else "Switching profile…",
color = Color(0xFFAAB1B7),
@@ -336,7 +664,7 @@ private fun ProfileChooser(
)
}
ProfileTile(
name = "Add someone else",
name = "Add another user",
current = false,
enabled = switchingProfileId == null,
symbol = "+",
@@ -346,7 +674,7 @@ private fun ProfileChooser(
}
if (onClose != null) {
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
private fun HomeScreen(settings: Settings) {
private fun HomeScreen(
settings: Settings,
onAddProfile: () -> Unit,
) {
val repo = ServiceLocator.repository
val context = LocalContext.current
val scope = rememberCoroutineScope()
val factory = remember(repo) { HomeViewModelFactory(repo) }
val homeViewModel: HomeViewModel = viewModel(factory = factory)
val homeState by homeViewModel.state.collectAsStateWithLifecycle()
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
var showSettings by remember { mutableStateOf(false) }
var showProfiles by remember { mutableStateOf(false) }
var addProfile by remember { mutableStateOf(false) }
var switchingProfileId by remember { mutableStateOf<String?>(null) }
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
var navigationExpanded by remember { mutableStateOf(false) }
var detailsItem 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 returnItemId by rememberSaveable { mutableStateOf<String?>(null) }
val navigationFocusRequester = remember { FocusRequester() }
val contentFocusRequester = remember { FocusRequester() }
val cardReturnFocusRequester = remember { FocusRequester() }
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 {
runCatching { repo.resolvePlayable(item) }.onSuccess { playable ->
context.startActivity(
PlayerActivity.intent(
context = context,
itemId = playable.itemId,
url = playable.url,
title = playable.title,
resumePositionMs = playable.resumePositionMs,
),
)
try {
runCatching { repo.resolvePlayable(item) }
.onSuccess { playable ->
context.startActivity(
PlayerActivity.intent(
context = context,
itemId = playable.itemId,
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()
.offset(x = contentShift),
) {
val maintenanceMessage = homeState.maintenanceMessage
val maintenanceMessage = liveMaintenance?.message ?: homeState.maintenanceMessage
if (maintenanceMessage != null) {
// 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.
@@ -575,13 +934,13 @@ private fun HomeScreen(settings: Settings) {
returnItemId = item.id
homeViewModel.focusItem(item)
homeViewModel.trackRowSelected(row.id, row.kind.name, item.id)
detailsItem = item
if (item.membyPlayable) detailsItem = item
},
onItemLongPressed = { item ->
returnRowId = row.id
returnItemId = item.id
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) {
BackHandler(onBack = { showSettings = false })
SettingsSheet(editableServer = true, onClose = { showSettings = false })
@@ -612,14 +976,11 @@ private fun HomeScreen(settings: Settings) {
},
onAddProfile = {
showProfiles = false
addProfile = true
onAddProfile()
},
onClose = { showProfiles = false },
)
}
if (addProfile) {
SetupScreen(onCancel = { addProfile = false })
}
detailsItem?.let { selected ->
BackHandler {
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
// overlay that happened to be open when the check came back.
homeState.update?.let { update ->
homeState.update?.takeIf { liveMaintenance == null }?.let { update ->
UpdateScreen(
update = update,
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
private fun HomeArtworkPreloader(
rows: List<HomeBrowseRow>,
@@ -804,13 +1244,18 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
.map { row ->
HomeBrowseRow(
id = row.id,
title = row.title,
title = if (row.kind == "favorites") {
personalizedFavoritesTitle(settings.username)
} else {
row.title
},
items = row.items,
kind = when (row.kind) {
"continue" -> MediaRowKind.CONTINUE
"nextup" -> MediaRowKind.NEXT_UP
"favorites" -> MediaRowKind.FAVORITES
"shows" -> MediaRowKind.SHOWS
"schedule" -> MediaRowKind.SHOWS
// Recommendation strips, "latest", and any kind a future server
// sends get poster cards, which suit mixed movie/series rows.
else -> MediaRowKind.MOVIES
@@ -821,6 +1266,7 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
"nextup" -> "You're all caught up"
"favorites" -> "Your favourites will appear here"
"latest" -> "No recent movies found"
"schedule" -> "No monitored shows are airing today"
else -> "Nothing to show here yet"
},
showSecondaryMetadata = settings.showHomeCardMetadata,
@@ -862,7 +1308,7 @@ private fun homeRowsFor(
)
val favorites = HomeBrowseRow(
id = "favorites",
title = "Favourites",
title = personalizedFavoritesTitle(settings.username),
items = state.favorites,
kind = MediaRowKind.FAVORITES,
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
private fun HeaderAction(label: String, onClick: () -> Unit) {
var focused by remember { mutableStateOf(false) }
@@ -1,12 +1,20 @@
package com.ponzischeme89.memby.ui.player
import android.app.AlertDialog
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.SystemClock
import android.text.format.DateFormat
import android.util.Log
import android.view.KeyEvent
import android.view.View
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.media3.common.C
import androidx.media3.common.MediaItem
@@ -15,14 +23,23 @@ import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.ui.PlayerView
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.ui.MainActivity
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.math.ceil
/**
* Fullscreen Media3 player with native stream-track selection. Press Menu while
@@ -37,6 +54,12 @@ class PlayerActivity : ComponentActivity() {
private var playbackStarted = false
private var stopReported = false
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
override fun onCreate(savedInstanceState: Bundle?) {
@@ -44,6 +67,10 @@ class PlayerActivity : ComponentActivity() {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val url = intent.getStringExtra(EXTRA_URL)
requestStartedAtMs = intent.getLongExtra(
EXTRA_REQUEST_STARTED_AT_MS,
SystemClock.elapsedRealtime(),
)
itemId = intent.getStringExtra(EXTRA_ITEM_ID)
val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L)
if (url.isNullOrBlank()) {
@@ -51,25 +78,54 @@ class PlayerActivity : ComponentActivity() {
return
}
val view = PlayerView(this).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
setContentView(R.layout.activity_player)
val view = findViewById<PlayerView>(R.id.player_view).apply {
useController = true
setShowSubtitleButton(true)
controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS
}
setContentView(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 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)
.setTrackSelector(selector)
.setLoadControl(loadControl)
.build()
.also { playback ->
view.player = playback
playback.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_BUFFERING -> showPlaybackLoading()
Player.STATE_READY -> if (renderedFirstFrame) hidePlaybackLoading()
}
if (playbackState == Player.STATE_READY && !playbackStarted) {
playbackStarted = true
reportStarted(playback.currentPosition)
@@ -80,11 +136,95 @@ class PlayerActivity : ComponentActivity() {
override fun onIsPlayingChanged(isPlaying: Boolean) {
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.playWhenReady = true
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 {
@@ -159,6 +299,7 @@ class PlayerActivity : ComponentActivity() {
override fun onDestroy() {
progressJob?.cancel()
loadingAnimator?.cancel()
val playback = player
if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) {
stopReported = true
@@ -189,7 +330,10 @@ class PlayerActivity : ComponentActivity() {
progressJob = lifecycleScope.launch {
while (isActive) {
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_TITLE = "extra_title"
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(
context: Context,
@@ -215,14 +361,34 @@ class PlayerActivity : ComponentActivity() {
url: String,
title: String?,
resumePositionMs: Long = 0L,
logoUrl: String? = null,
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
): Intent =
Intent(context, PlayerActivity::class.java).apply {
itemId?.let { putExtra(EXTRA_ITEM_ID, it) }
putExtra(EXTRA_URL, url)
putExtra(EXTRA_TITLE, title)
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 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>
<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="screensaver_name">Memby Screensaver</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)
}
@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
fun `a home payload without rows still decodes`() {
// The gateway omits recommendation rows while they are still building, and an
@@ -106,4 +144,14 @@ class GatewayPayloadTest {
assertEquals(42_000L, playback.resumePositionMs)
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)
}
@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
fun `disabling a section hides its rows but never the recommendations`() {
val settings = Settings(homeSections = "continue")
@@ -67,6 +93,20 @@ class ServerHomeRowsTest {
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
fun `an unknown row kind from a newer server still renders`() {
val rows = serverHomeRows(
+21
View File
@@ -8,6 +8,11 @@ services:
- "${MEMBY_PORT:-8080}:8080"
environment:
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.
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
@@ -16,15 +21,30 @@ services:
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_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
# analytics page all live behind it.
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
# films appearing weekly ride along.
MEMBY_SYNC_INTERVAL: "${MEMBY_SYNC_INTERVAL:-1h}"
MEMBY_SYNC_ON_START: "${MEMBY_SYNC_ON_START:-false}"
MEMBY_SYNC_USER_ID: "${MEMBY_SYNC_USER_ID:-}"
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:
postgres:
condition: service_healthy
@@ -66,3 +86,4 @@ services:
volumes:
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
# 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+
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.
$code = [int]$parts[0] * 10000 + [int]$parts[1] * 100 + [int]$parts[2]
$gradle = $gradle -replace 'versionCode = \d+', "versionCode = $code"
$gradle = $gradle -replace 'versionName = "[^"]*"', "versionName = `"$Version`""
$gradle = $gradle -replace 'val defaultVersionName = "[^"]*"', "val defaultVersionName = `"$Version`""
Set-Content -LiteralPath $gradleFile -Value $gradle -NoNewline
Write-Host "Set version $Version (versionCode $code)" -ForegroundColor Cyan
} 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]
Write-Host "Building existing version $Version" -ForegroundColor Cyan
}
+2
View File
@@ -8,6 +8,7 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN mkdir -p /out/releases
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
-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
WORKDIR /app
COPY --from=build /out/memby-server /app/memby-server
COPY --from=build --chown=nonroot:nonroot /out/releases /data/releases
EXPOSE 8080
USER nonroot:nonroot
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`.
## 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
All `/v1` routes need `Authorization: Bearer <token>` from `/v1/auth/login`. Image URLs
@@ -43,9 +54,11 @@ headers attached.
| 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 |
| 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/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild |
| 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": [
{"id": "continue", "title": "Continue Watching", "kind": "continue", "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": "latest-movies", "title": "Recently Added Movies", "kind": "latest", "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": [...],
"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
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.
- **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
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
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
@@ -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
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
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
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
`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
`{"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
the app is deliberately off.
a network error. The exact `/v1/status` control route stays up too: open clients poll it
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
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
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
`none`, `optional` or `mandatory`.
`X-Memby-Version` on every request and ask `GET /v1/update` on each launch and hourly
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
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).
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
@@ -240,18 +315,27 @@ losing Postgres signs everyone out.
| `MEMBY_DATABASE_URL` | *required* | Postgres DSN |
| `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | |
| `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_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_TIMEOUT` | `60s` | Bounds a background rebuild |
| `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_TIMEOUT` | `30m` | Bounds one import |
| `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_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_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` | |
## Security notes
+39 -2
View File
@@ -4,6 +4,7 @@ package main
import (
"context"
"encoding/hex"
"errors"
"flag"
"fmt"
@@ -20,7 +21,9 @@ import (
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
"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/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -37,7 +40,8 @@ func main() {
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 {
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)
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)
// Candidates come from the imported library when one exists, which keeps the
@@ -99,6 +131,7 @@ func run(log *slog.Logger) error {
Store: st,
Cache: ca,
Recommender: recommender,
Sonarr: sonarrClient,
Syncer: syncer,
Log: log,
})
@@ -135,7 +168,11 @@ func run(log *slog.Logger) error {
errCh := make(chan error, 1)
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) {
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/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
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) {
server := testServer(config.Config{})
+41 -3
View File
@@ -18,12 +18,14 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -33,8 +35,10 @@ type Server struct {
store *store.Store
cache *cache.Cache
recommender *recommend.Engine
sonarr *sonarr.Client
syncer syncerHandle
log *slog.Logger
sonarrMu sync.Mutex
recommendationBuilds recommendationBuilds
maintenance maintenanceState
@@ -48,17 +52,22 @@ type Deps struct {
Store *store.Store
Cache *cache.Cache
Recommender *recommend.Engine
Sonarr *sonarr.Client
Syncer syncerHandle
Log *slog.Logger
}
func New(cfg config.Config, deps Deps) *Server {
if cfg.MaxClientsPerUser < 1 {
cfg.MaxClientsPerUser = 1
}
return &Server{
cfg: cfg,
emby: deps.Emby,
store: deps.Store,
cache: deps.Cache,
recommender: deps.Recommender,
sonarr: deps.Sonarr,
syncer: deps.Syncer,
log: deps.Log,
}
@@ -70,6 +79,7 @@ func (s *Server) Routes() http.Handler {
v1 := http.NewServeMux()
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("GET /v1/auth/session", s.authed(s.handleSession))
@@ -93,8 +103,12 @@ func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth)
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("/admin/", s.adminRoutes())
mux.HandleFunc("GET /updates/{filename}", s.handleReleaseDownload)
return s.withLogging(mux)
}
@@ -134,15 +148,33 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
// 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,
"path", r.URL.Path,
"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 {
http.ResponseWriter
status int
@@ -184,6 +216,7 @@ type cachedSession struct {
Username string `json:"n"`
ServerID string `json:"s"`
DeviceID string `json:"d"`
DeviceName string `json:"dn,omitempty"`
}
// 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,
ServerID: cs.ServerID,
DeviceID: cs.DeviceID,
DeviceName: cs.DeviceName,
}, nil
}
}
@@ -220,6 +254,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
Username: sess.Username,
ServerID: sess.ServerID,
DeviceID: sess.DeviceID,
DeviceName: sess.DeviceName,
}); err == nil {
_ = 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 {
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 --------------------------------------------------------------
+59
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
"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
// client's non-null List fields.
func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) {
+78 -16
View File
@@ -2,24 +2,39 @@ package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
DeviceID string `json:"deviceId"`
Username string `json:"username"`
Password string `json:"password"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
}
type loginResponse struct {
Token string `json:"token"`
UserID string `json:"userId"`
Username string `json:"username"`
ServerID string `json:"serverId"`
Token string `json:"token"`
UserID string `json:"userId"`
Username string `json:"username"`
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.
@@ -40,8 +55,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if req.DeviceID == "" {
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 {
// Never echo Emby's body here: a failed sign-in is the one place a wrong
// password could be reflected back.
@@ -64,21 +92,54 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
Username: auth.User.Name,
ServerID: auth.ServerID,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
}
if sess.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)
writeError(w, http.StatusInternalServerError, "could not start a session")
return
}
if len(replacedHash) > 0 {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(replacedHash)))
}
writeJSON(w, http.StatusOK, loginResponse{
Token: token,
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
Token: token,
UserID: sess.EmbyUserID,
Username: sess.Username,
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.
func (s *Server) handleSession(w http.ResponseWriter, _ *http.Request, sess store.Session) {
writeJSON(w, http.StatusOK, loginResponse{
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
})
}
+33 -5
View File
@@ -8,6 +8,7 @@ import (
"strconv"
"sync"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"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)
var (
mu sync.Mutex
failures int
out homeResponse
wg sync.WaitGroup
mu sync.Mutex
failures int
out homeResponse
sonarrRow *recommend.Row
wg sync.WaitGroup
)
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)},
}, 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()
@@ -136,7 +152,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
if recommendations == nil {
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)
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)
}
// 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
// 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) {
+52
View File
@@ -1,11 +1,14 @@
package api
import (
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"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")
return
}
if strings.HasPrefix(itemID, "sonarr:") {
s.handleSonarrImage(w, r, itemID, imageType)
return
}
params := url.Values{}
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)
}
}
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"
"net/http"
"net/url"
"strconv"
"strings"
"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)
raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName")
if err != nil {
s.writeUpstreamError(w, err, "could not load the item")
return
}
item, err := emby.Summarise(raw)
if err != nil {
writeError(w, http.StatusBadGateway, "unreadable item from emby")
return
item, hinted := playbackHint(r, itemID)
if !hinted {
raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName")
if err != nil {
s.writeUpstreamError(w, err, "could not load the item")
return
}
item, err = emby.Summarise(raw)
if err != nil {
writeError(w, http.StatusBadGateway, "unreadable item from emby")
return
}
}
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.
func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials, seriesID string) (*emby.Summary, error) {
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
}
// 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.
func compare(a, b []int) int {
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
// wipe that every favourite toggle triggers. Only a genuine change in viewing history
// — 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 {
return c.Delete(ctx, RecommendationsKey(userID))
+54 -1
View File
@@ -7,6 +7,7 @@ import (
"strconv"
"strings"
"time"
_ "time/tzdata"
)
type Config struct {
@@ -33,6 +34,8 @@ type Config struct {
SessionTTL time.Duration
// SessionIdleExpiry retires gateway tokens that go unused for this long.
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
// taste moves slowly and each rebuild costs several Emby queries.
@@ -47,6 +50,14 @@ type Config struct {
// unconfigured deployment cannot leave it exposed.
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 time.Duration
// 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 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) {
@@ -76,10 +94,16 @@ func Load() (Config, error) {
ScreensaverTTL: duration("MEMBY_SCREENSAVER_TTL", 10*time.Minute),
SessionTTL: duration("MEMBY_SESSION_CACHE_TTL", 5*time.Minute),
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),
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),
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
@@ -87,6 +111,9 @@ func Load() (Config, error) {
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
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 == "" {
@@ -95,9 +122,23 @@ func Load() (Config, error) {
if c.DatabaseURL == "" {
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 == "" {
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
}
@@ -134,3 +175,15 @@ func duration(key string, fallback time.Duration) time.Duration {
}
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.
type Credentials struct {
UserID string
Token string
DeviceID string
UserID string
Token string
DeviceID string
DeviceName string
}
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})
if err != nil {
return nil, err
}
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 {
return nil, err
}
@@ -104,6 +105,16 @@ func (c *Client) Authenticate(ctx context.Context, username, password, deviceID
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) {
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 == "" {
deviceID = "memby-gateway"
}
deviceName := strings.TrimSpace(cred.DeviceName)
if deviceName == "" {
deviceName = "Memby TV"
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Emby-Authorization", fmt.Sprintf(
`MediaBrowser Client="%s", Device="Memby Gateway", DeviceId="%s", Version="1.0"`,
c.clientName, deviceID,
`MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="1.0"`,
c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID,
))
if 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
}
s.log.Info("library sync started", "kind", kind, "trigger", trigger)
result, syncErr := s.run(ctx, cred, kind, since, startedAt)
result.Kind = kind
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",
"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
}
@@ -204,6 +206,11 @@ func (s *Syncer) run(
result.Seen += len(page.Items)
result.Upserted += int(written)
s.log.Info("library sync progress",
"kind", kind,
"seen", result.Seen,
"upserted", result.Upserted,
)
if len(page.Items) < pageSize {
break
+110 -12
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"log/slog"
"net/url"
"sort"
"strconv"
"strings"
"sync"
@@ -33,6 +34,27 @@ type LibrarySource interface {
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 {
source Source
log *slog.Logger
@@ -47,6 +69,7 @@ type Engine struct {
// screen rather than a wall of near-duplicates.
MaxSimilarRows int
RowSize int
CuratedRows []CuratedRow
}
func NewEngine(source Source, log *slog.Logger) *Engine {
@@ -56,6 +79,29 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
MinRowItems: 4,
MaxSimilarRows: 2,
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)
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)
for _, seed := range e.seedsFor(profile) {
row, ok := e.similarRow(ctx, cred, profile, seed)
if ok {
rows := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
if !profile.IsEmpty() {
for _, seed := range e.seedsFor(profile) {
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)
}
}
if row, ok := e.historyRow(ctx, cred, profile); ok {
rows = append(rows, row)
}
rows = append(rows, e.buildCuratedRows(ctx, profile)...)
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.
func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (history, favorites []Item, err error) {
var (
+86
View File
@@ -27,6 +27,31 @@ type fakeSource struct {
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) {
f.mu.Lock()
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.
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
source := &fakeSource{
+32 -1
View File
@@ -193,8 +193,39 @@ func (p Profile) Score(candidate Item) float64 {
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.
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 {
item Item
score float64
@@ -207,7 +238,7 @@ func Rank(profile Profile, candidates []Item, limit int) []Item {
continue
}
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})
}
}
+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) {
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"}`)})
+45
View File
@@ -138,6 +138,51 @@ func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit in
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) {
stats := LibraryStats{ByType: map[string]int64{}}
+16
View File
@@ -10,12 +10,28 @@ CREATE TABLE IF NOT EXISTS sessions (
username TEXT NOT NULL,
server_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(),
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_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.
--
+104 -11
View File
@@ -17,6 +17,7 @@ var schema string
// ErrNotFound is returned when a token does not match a live session.
var ErrNotFound = errors.New("store: session not found")
var ErrDeviceLimit = errors.New("store: device limit reached")
type Session struct {
TokenHash []byte
@@ -25,6 +26,7 @@ type Session struct {
Username string
ServerID string
DeviceID string
DeviceName string
LastSeenAt time.Time
}
@@ -56,30 +58,73 @@ func (s *Store) Migrate(ctx context.Context) error {
return nil
}
func (s *Store) CreateSession(ctx context.Context, sess Session) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO sessions (token_hash, emby_user_id, emby_token, username, server_id, device_id)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (token_hash) DO UPDATE SET
// CreateSession enforces a user's device allowance under a per-user transaction lock.
// Re-authenticating the same stable device replaces its token and never consumes a slot.
// The replaced hash is returned so its Redis entry can be invalidated immediately.
func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int) ([]byte, int, error) {
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,
username = EXCLUDED.username,
server_id = EXCLUDED.server_id,
device_id = EXCLUDED.device_id,
device_name = EXCLUDED.device_name,
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 {
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) {
var sess Session
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).
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) {
return Session{}, ErrNotFound
}
@@ -111,3 +156,51 @@ func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int
}
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
}