From 8d6cf2f5a1a06e674c1099b5571ab43a534b09dc Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Mon, 27 Jul 2026 21:06:51 +1200 Subject: [PATCH] Server changes/Sonarr --- .env.example | 21 + app/build.gradle.kts | 21 +- .../com/ponzischeme89/memby/ServiceLocator.kt | 4 + .../memby/data/EmbyRepository.kt | 172 +++- .../ponzischeme89/memby/data/SettingsStore.kt | 96 ++- .../memby/data/model/EmbyModels.kt | 11 + .../memby/data/model/GatewayModels.kt | 23 + .../memby/data/remote/GatewayApi.kt | 16 +- .../ponzischeme89/memby/ui/HomeComponents.kt | 191 ++++- .../ponzischeme89/memby/ui/HomeViewModel.kt | 32 +- .../ponzischeme89/memby/ui/MainActivity.kt | 739 ++++++++++++++---- .../memby/ui/player/PlayerActivity.kt | 182 ++++- app/src/main/res/values/strings.xml | 3 + .../memby/data/GatewayPayloadTest.kt | 48 ++ .../memby/ui/ServerHomeRowsTest.kt | 40 + docker-compose.yml | 21 + gradle.properties | 2 +- release.ps1 | 7 +- server/Dockerfile | 2 + server/README.md | 102 ++- server/cmd/memby-server/main.go | 41 +- server/internal/api/admin.go | 1 + server/internal/api/admin_test.go | 22 + server/internal/api/api.go | 44 +- server/internal/api/api_test.go | 59 ++ server/internal/api/auth.go | 94 ++- server/internal/api/home.go | 38 +- server/internal/api/images.go | 52 ++ server/internal/api/maintenance.go | 15 + server/internal/api/playback.go | 47 +- server/internal/appupdate/policy.go | 4 + server/internal/cache/cache.go | 2 +- server/internal/config/config.go | 55 +- server/internal/emby/client.go | 29 +- server/internal/library/syncer.go | 9 +- server/internal/recommend/engine.go | 122 ++- server/internal/recommend/engine_test.go | 86 ++ server/internal/recommend/profile.go | 33 +- server/internal/recommend/profile_test.go | 10 + server/internal/store/library.go | 45 ++ server/internal/store/schema.sql | 16 + server/internal/store/store.go | 115 ++- 42 files changed, 2388 insertions(+), 284 deletions(-) diff --git a/.env.example b/.env.example index 86060c7..67de645 100644 --- a/.env.example +++ b/.env.example @@ -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://: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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6ebbfad..b44247c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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("\"", "\\\"")}\"") diff --git a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt index 9a514e7..9e5eeab 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt @@ -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) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 47baea8..d051f48 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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 get() = settings.settingsFlow private val _playbackStops = MutableSharedFlow(extraBufferCapacity = 1) val playbackStops = _playbackStops.asSharedFlow() + private val playableMutex = Mutex() + private val playableCache = LinkedHashMap(16, 0.75f, true) + private val playableInFlight = mutableMapOf>() 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 { + 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(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. diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt index 5d53de7..0480ce6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -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 = 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 = 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(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 = value?.let { runCatching { Json.decodeFromString>(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 = decodeProfiles(preferences[Keys.PROFILES]).ifEmpty { legacyProfile(preferences)?.let(::listOf).orEmpty() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt index 253e0f8..291fd9b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt @@ -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? diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index beddb4e..7532cc1 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -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( diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index cfab15b..f92cdd6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index e901cbe..1121b51 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -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, +) + +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( + "Matt’s 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 { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index beccd50..5b35c9a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 8a82c50..b3c2fc9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -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(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( + "Who’s 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(null) } + var maxClientsPerUser by remember { mutableStateOf(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 “Dad’s 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(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("Who’s watching Emby?", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.SemiBold) + Text("Who’s 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(null) } var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } var navigationExpanded by remember { mutableStateOf(false) } var detailsItem by remember { mutableStateOf(null) } var quickMenuItem by remember { mutableStateOf(null) } + var launchingItem by remember { mutableStateOf(null) } var returnRowId by rememberSaveable { mutableStateOf(null) } var returnItemId by rememberSaveable { mutableStateOf(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, "Couldn’t 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, @@ -804,13 +1244,18 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List 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 "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) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 0ea823a..90634c2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -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(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(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(R.id.player_title_logo) + val fallback = findViewById(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" + } + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8cd98b2..2a952fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,7 @@ + Title logo + Starting playback… + Connecting directly to Emby Memby Memby Screensaver Memby movie & TV backdrops diff --git a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt index e389676..00a8ae8 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt @@ -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(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) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt index b459630..aa97e22 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt @@ -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( diff --git a/docker-compose.yml b/docker-compose.yml index ddce00e..dc9080a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/gradle.properties b/gradle.properties index 0b5cafc..12b9321 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/release.ps1 b/release.ps1 index f5144bd..a784195 100644 --- a/release.ps1 +++ b/release.ps1 @@ -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 } diff --git a/server/Dockerfile b/server/Dockerfile index 6762777..964cf0a 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -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"] diff --git a/server/README.md b/server/README.md index 6a4ce08..d613e44 100644 --- a/server/README.md +++ b/server/README.md @@ -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 ` 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::rows` +**The home screen never waits on the engine.** Rows live in their own `r::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://: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 diff --git a/server/cmd/memby-server/main.go b/server/cmd/memby-server/main.go index 774731b..d8d3a43 100644 --- a/server/cmd/memby-server/main.go +++ b/server/cmd/memby-server/main.go @@ -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 } diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 668cf4e..5c5efbe 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -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 } diff --git a/server/internal/api/admin_test.go b/server/internal/api/admin_test.go index 10384dd..dbd9a11 100644 --- a/server/internal/api/admin_test.go +++ b/server/internal/api/admin_test.go @@ -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{}) diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 212dcf1..178f2ad 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -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 -------------------------------------------------------------- diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index ba92b58..3adc924 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -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) { diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go index b5d8a74..3ce48de 100644 --- a/server/internal/api/auth.go +++ b/server/internal/api/auth.go @@ -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, }) } diff --git a/server/internal/api/home.go b/server/internal/api/home.go index 067c76f..5d3a63c 100644 --- a/server/internal/api/home.go +++ b/server/internal/api/home.go @@ -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) { diff --git a/server/internal/api/images.go b/server/internal/api/images.go index 0a86556..f3a5e57 100644 --- a/server/internal/api/images.go +++ b/server/internal/api/images.go @@ -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) + } +} diff --git a/server/internal/api/maintenance.go b/server/internal/api/maintenance.go index 2c1125e..2baf563 100644 --- a/server/internal/api/maintenance.go +++ b/server/internal/api/maintenance.go @@ -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, + }) +} diff --git a/server/internal/api/playback.go b/server/internal/api/playback.go index e98d3b1..9ca0d14 100644 --- a/server/internal/api/playback.go +++ b/server/internal/api/playback.go @@ -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{ diff --git a/server/internal/appupdate/policy.go b/server/internal/appupdate/policy.go index b5211ce..30aaec1 100644 --- a/server/internal/appupdate/policy.go +++ b/server/internal/appupdate/policy.go @@ -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++ { diff --git a/server/internal/cache/cache.go b/server/internal/cache/cache.go index 7d2322b..021a3e3 100644 --- a/server/internal/cache/cache.go +++ b/server/internal/cache/cache.go @@ -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)) diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 88903c4..697e4a4 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -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 +} diff --git a/server/internal/emby/client.go b/server/internal/emby/client.go index 475c5fd..b6b219a 100644 --- a/server/internal/emby/client.go +++ b/server/internal/emby/client.go @@ -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) diff --git a/server/internal/library/syncer.go b/server/internal/library/syncer.go index e7933a3..2091258 100644 --- a/server/internal/library/syncer.go +++ b/server/internal/library/syncer.go @@ -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 diff --git a/server/internal/recommend/engine.go b/server/internal/recommend/engine.go index 1fc9191..04b6586 100644 --- a/server/internal/recommend/engine.go +++ b/server/internal/recommend/engine.go @@ -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 ( diff --git a/server/internal/recommend/engine_test.go b/server/internal/recommend/engine_test.go index 2bdde55..d249b19 100644 --- a/server/internal/recommend/engine_test.go +++ b/server/internal/recommend/engine_test.go @@ -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{ diff --git a/server/internal/recommend/profile.go b/server/internal/recommend/profile.go index f61118d..d4a680c 100644 --- a/server/internal/recommend/profile.go +++ b/server/internal/recommend/profile.go @@ -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}) } } diff --git a/server/internal/recommend/profile_test.go b/server/internal/recommend/profile_test.go index d4fd2ca..a641458 100644 --- a/server/internal/recommend/profile_test.go +++ b/server/internal/recommend/profile_test.go @@ -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"}`)}) diff --git a/server/internal/store/library.go b/server/internal/store/library.go index 09ded94..789e93f 100644 --- a/server/internal/store/library.go +++ b/server/internal/store/library.go @@ -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{}} diff --git a/server/internal/store/schema.sql b/server/internal/store/schema.sql index e314095..e42c8d1 100644 --- a/server/internal/store/schema.sql +++ b/server/internal/store/schema.sql @@ -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. -- diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 6a29295..d6388ec 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -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 +}