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