Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:55 +12:00
parent 70914400b4
commit a265636139
73 changed files with 9593 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
name: Publish Memby release
on:
push:
tags:
- "v*.*.*"
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android 35 build tools
shell: bash
run: sdkmanager "platforms;android-35" "build-tools;35.0.0"
- name: Read version from tag
shell: bash
run: |
VERSION="${GITEA_REF_NAME#v}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag must look like v0.1.54" >&2
exit 1
fi
echo "VERSION=$VERSION" >> "$GITEA_ENV"
- name: Restore release keystore
shell: bash
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$GITEA_WORKSPACE/memby-release.jks"
- name: Test and build signed APK
shell: bash
env:
MEMBY_KEYSTORE: ${{ gitea.workspace }}/memby-release.jks
MEMBY_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
MEMBY_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
MEMBY_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
chmod +x ./gradlew
./gradlew --console=plain testDebugUnitTest assembleRelease \
-Pmemby.versionName="$VERSION"
- name: Publish to Memby gateway
shell: bash
env:
RELEASE_TOKEN: ${{ secrets.MEMBY_RELEASE_PUBLISH_TOKEN }}
run: |
NOTES="$(git log -1 --pretty=%B)"
curl --fail-with-body --show-error --silent \
-X POST \
-H "Authorization: Bearer $RELEASE_TOKEN" \
-F "version=$VERSION" \
-F "notes=$NOTES" \
-F "apk=@app/build/outputs/apk/release/app-release.apk;type=application/vnd.android.package-archive" \
https://mserver.sublogue.com/admin/api/release
@@ -0,0 +1,220 @@
package com.ponzischeme89.memby.data
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.ponzischeme89.memby.data.model.GatewayAlert
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
data class MaintenanceNotice(val message: String)
data class CompatibilityNotice(val message: String)
/**
* One informational banner: a show aired, its episode is on its way into Emby. It is
* never actionable and never focusable — it slides in, says its piece and goes.
*/
data class ServiceAlert(
val id: String,
val title: String,
val message: String,
val posterUrl: String?,
)
/**
* One process-wide control channel shared by every Memby activity.
*
* A network failure leaves the last confirmed state intact: losing connectivity while a
* maintenance notice is showing must not briefly reopen playback. A successful status
* response is the only thing that enters or clears maintenance.
*
* The same poll carries alerts, so news reaches an open app without a second connection.
* The gateway keeps offering an alert for as long as it is current and has no idea which
* TVs have seen it, so *this* side owns "shown already" — persisted, or every relaunch
* would replay yesterday's news.
*
* The loop runs only while a Memby screen is in the foreground, and an alert counts as
* shown only when the banner says so ([alertShown]). Both exist for the same reason: work
* done for a screen nobody is looking at is worse than wasted, because it also burns the
* one chance to deliver the news.
*/
class MaintenanceMonitor(
private val repository: EmbyRepository,
private val settings: SettingsStore,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _notice = MutableStateFlow<MaintenanceNotice?>(null)
val notice: StateFlow<MaintenanceNotice?> = _notice.asStateFlow()
private val _compatibility = MutableStateFlow<CompatibilityNotice?>(null)
val compatibility: StateFlow<CompatibilityNotice?> = _compatibility.asStateFlow()
private val _alert = MutableStateFlow<ServiceAlert?>(null)
val alert: StateFlow<ServiceAlert?> = _alert.asStateFlow()
private val seenAlertIds = mutableSetOf<String>()
private var seenAlertsLoaded = false
private var shownAlertId: String? = null
private var alertTimer: Job? = null
init {
scope.launchStatusLoop()
}
/**
* Called by the banner once it is actually on screen. Until this arrives the alert is
* only *offered*: nothing is persisted and no timer runs, so an alert that lands while
* the screensaver is up or another app is in front survives to be shown later rather
* than being consumed by nobody.
*
* Seen is recorded on display rather than on dismissal, because a banner interrupted
* by a crash or a power cut is not worth replaying a day later.
*/
fun alertShown(id: String) {
if (shownAlertId == id) return
shownAlertId = id
seenAlertIds += id
scope.launch { runCatching { settings.markAlertSeen(id) } }
alertTimer?.cancel()
alertTimer = scope.launch {
delay(ALERT_VISIBLE_MS)
if (_alert.value?.id == id) _alert.value = null
}
}
/** Hides the current banner early — the viewer has already read it. */
fun dismissAlert() {
alertTimer?.cancel()
shownAlertId = null
_alert.value = null
}
private fun CoroutineScope.launchStatusLoop() = launch {
// Only while a Memby screen is in front. Backgrounded, this loop is cancelled
// outright rather than left ticking a request every ten seconds at a TV nobody
// is looking at; coming back to the foreground restarts it with an immediate
// poll, so maintenance is still caught the moment the viewer returns.
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
repository.settingsFlow.collectLatest { session ->
if (!ServerConfig.isGateway || !session.isSignedIn) {
_notice.value = null
_compatibility.value = null
dismissAlert()
return@collectLatest
}
while (isActive) {
runCatching { repository.serviceStatus() }
.onSuccess { status ->
_compatibility.value = if (status.compatible) {
null
} else {
CompatibilityNotice(
status.compatibilityMessage.trim().ifEmpty {
"This Memby app and server are not compatible. Update the app or contact the server administrator."
},
)
}
_notice.value = if (status.maintenance) {
MaintenanceNotice(
status.message.trim().ifEmpty {
"Memby is down for maintenance. Try again shortly."
},
)
} else {
null
}
if (status.maintenance) {
// The maintenance screen owns the display; anything
// cheerful in front of it would only be confusing.
dismissAlert()
} else {
offerNextAlert(status.alerts)
}
}
.onFailure { error ->
if (isUnauthorizedError(error)) {
repository.invalidateSession()
_notice.value = null
_compatibility.value = null
dismissAlert()
return@collectLatest
}
}
delay(POLL_INTERVAL_MS)
}
}
}
}
private suspend fun offerNextAlert(alerts: List<GatewayAlert>) {
if (!seenAlertsLoaded) {
seenAlertIds += runCatching { settings.seenAlertIds() }.getOrDefault(emptySet())
seenAlertsLoaded = true
}
val pending = _alert.value
if (pending != null) {
if (pendingAlertExpired(pending.id, shownAlertId, alerts)) {
_alert.value = null
}
// One at a time either way; a queued second alert is still unseen next poll.
return
}
val next = firstUnseenAlert(alerts, seenAlertIds) ?: return
_alert.value = ServiceAlert(
id = next.id,
title = next.title.trim(),
message = next.message.trim(),
posterUrl = repository.posterUrl(next.itemId, next.imageTag),
)
}
companion object {
internal const val POLL_INTERVAL_MS = 10_000L
/**
* How long one banner stays on screen. The banner draws a ring counting this
* down, so the two must agree — take the duration from here rather than
* hard-coding it in the UI.
*/
const val ALERT_VISIBLE_MS = 10_000L
}
}
/**
* Whether an alert held for a screen that never appeared should be given up on.
*
* An offered alert waits indefinitely for a foreground banner, so something has to end
* that wait: once the gateway stops listing it, the episode aired long enough ago that
* announcing it is no longer news. An alert already shown is left alone — its own
* dismissal timer owns it.
*/
internal fun pendingAlertExpired(
pendingId: String,
shownId: String?,
alerts: List<GatewayAlert>,
): Boolean = pendingId != shownId && alerts.none { it.id == pendingId }
/**
* The first alert this TV has not shown before. Alerts arrive newest-first, and anything
* missing an id, a title or a message is dropped rather than rendered as an empty card.
*/
internal fun firstUnseenAlert(alerts: List<GatewayAlert>, seen: Set<String>): GatewayAlert? =
alerts.firstOrNull { alert ->
alert.id.isNotBlank() &&
alert.id !in seen &&
alert.title.isNotBlank() &&
alert.message.isNotBlank()
}
@@ -0,0 +1,92 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.MediaStream
import kotlinx.serialization.Serializable
import java.net.URI
import java.net.URLEncoder
@Serializable
data class PlayableSubtitle(
val id: String = "",
val url: String,
val mimeType: String,
val language: String? = null,
val label: String? = null,
val isDefault: Boolean = false,
val isForced: Boolean = false,
val isHearingImpaired: Boolean = false,
val deliveryMethod: String = "External",
val codec: String? = null,
)
internal fun subtitleTracks(
streams: List<MediaStream>,
serverUrl: String,
token: String,
itemId: String,
mediaSourceId: String,
): List<PlayableSubtitle> = streams.mapNotNull { stream ->
if (!stream.type.equals("Subtitle", true) || stream.index < 0) {
return@mapNotNull null
}
val sourceId = mediaSourceId.ifBlank { itemId }
val method = stream.deliveryMethod?.takeIf(String::isNotBlank)
?: if (stream.isTextSubtitleStream) "External" else "Encode"
val mime = subtitleMimeType(stream.codec, stream.deliveryUrl.orEmpty())
val extension = subtitleExtension(stream.codec, stream.deliveryUrl.orEmpty())
val canonical = "/Videos/${pathSegment(itemId)}/${pathSegment(sourceId)}" +
"/Subtitles/${stream.index}/Stream.$extension"
val delivery = stream.deliveryUrl?.takeIf(String::isNotBlank) ?: canonical
PlayableSubtitle(
id = stream.index.toString(),
url = if (method.equals("External", true) && mime != null) {
authenticatedDeliveryUrl(serverUrl, delivery, token)
} else "",
mimeType = mime.orEmpty(),
language = stream.language?.trim()?.takeIf(String::isNotEmpty),
label = (stream.displayTitle ?: stream.title)?.trim()?.takeIf(String::isNotEmpty),
isDefault = stream.isDefault,
isForced = stream.isForced,
isHearingImpaired = stream.isHearingImpaired || listOfNotNull(stream.title, stream.displayTitle).any {
it.contains("sdh", true) || it.contains("hearing", true)
},
deliveryMethod = method,
codec = stream.codec,
)
}.distinctBy { it.id }
private fun subtitleExtension(codec: String?, url: String): String =
when (codec?.trim()?.lowercase()) {
"subrip" -> "srt"
"webvtt" -> "vtt"
"tx3g" -> "mov_text"
else -> codec?.trim()?.lowercase()?.takeIf(String::isNotEmpty)
?: url.substringBefore('?').substringAfterLast('.', "vtt").lowercase()
}
private fun pathSegment(value: String): String =
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
internal fun subtitleMimeType(codec: String?, url: String = ""): String? =
when ((codec?.trim()?.lowercase()?.takeIf(String::isNotEmpty)
?: url.substringBefore('?').substringAfterLast('.', "").lowercase())) {
"srt", "subrip" -> "application/x-subrip"
"vtt", "webvtt" -> "text/vtt"
"ass", "ssa" -> "text/x-ssa"
"ttml", "dfxp" -> "application/ttml+xml"
"tx3g", "mov_text" -> "application/x-quicktime-tx3g"
else -> null
}
internal fun authenticatedDeliveryUrl(serverUrl: String, deliveryUrl: String, token: String): String {
val absolute = if (runCatching { URI(deliveryUrl).isAbsolute }.getOrDefault(false)) {
deliveryUrl
} else {
serverUrl.trimEnd('/') + "/" + deliveryUrl.trimStart('/')
}
if (token.isBlank() || Regex("""(?:[?&])api_key=""", RegexOption.IGNORE_CASE).containsMatchIn(absolute)) {
return absolute
}
val separator = if ('?' in absolute) '&' else '?'
return absolute + separator + "api_key=" + URLEncoder.encode(token, "UTF-8")
}
@@ -0,0 +1,47 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import com.ponzischeme89.memby.ui.theme.MembyTheme
/**
* The one preview shape this app uses: a 1080p TV, landscape, on the launcher's own
* near-black. A phone-sized preview would be actively misleading — every layout here is
* built for a 10-foot view and D-pad focus.
*
* Previews render the composable only; they do not run `ServiceLocator`, so anything
* previewable has to take its state as parameters rather than reading the repository.
* That is worth keeping: it is the same property that makes a composable testable.
*/
@Preview(
name = "TV 1080p",
device = Devices.TV_1080p,
showBackground = true,
backgroundColor = 0xFF090B0D,
)
annotation class TvPreview
/** Wraps preview content in the real theme, so type and colours match the running app. */
@Composable
fun PreviewSurface(
alignment: Alignment = Alignment.Center,
content: @Composable () -> Unit,
) {
MembyTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF090B0D)),
contentAlignment = alignment,
) {
content()
}
}
}
@@ -0,0 +1,19 @@
package com.ponzischeme89.memby.ui
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
/**
* A lifecycle boundary for state that belongs to one signed-in Emby profile.
*
* The owner is remembered by the profile screen and cleared when that screen leaves
* composition. Clearing it cancels the old profile's ViewModel jobs as well as dropping
* its rows and caches before another profile is rendered.
*/
internal class ProfileViewModelStoreOwner : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
fun clear() {
viewModelStore.clear()
}
}
@@ -0,0 +1,574 @@
package com.ponzischeme89.memby.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Button
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import kotlinx.coroutines.delay
private val SeriesGreen = Color(0xFF52B54B)
private val SeriesMuted = Color(0xFFD0D6DB)
private val SeriesQuiet = Color(0xFFAEB7BF)
@Composable
fun SeriesDetailsOverlay(
item: BaseItem,
onPlay: (BaseItem) -> Unit,
onToggleFavorite: (BaseItem, Boolean) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val repository = ServiceLocator.repository
var episodes by remember(item.id) { mutableStateOf<List<BaseItem>?>(null) }
var loadFailed by remember(item.id) { mutableStateOf(false) }
var selectedSectionKey by rememberSaveable(item.id) {
mutableStateOf(SeriesDetailSection.EPISODES.key)
}
val selectedSection = seriesDetailSection(selectedSectionKey)
LaunchedEffect(item.id) {
runCatching { repository.getSeriesEpisodes(item.id) }
.onSuccess { episodes = it.sortedWith(seriesEpisodeComparator) }
.onFailure {
loadFailed = true
episodes = emptyList()
}
}
val seasons = remember(episodes) { availableSeasons(episodes.orEmpty()) }
var selectedSeason by rememberSaveable(item.id) { mutableStateOf<Int?>(null) }
LaunchedEffect(seasons) {
if (selectedSeason !in seasons) {
selectedSeason = defaultSeason(episodes.orEmpty())
}
}
val seasonEpisodes = remember(episodes, selectedSeason) {
episodesForSeason(episodes.orEmpty(), selectedSeason)
}
val firstEpisode = remember { FocusRequester() }
val seasonFocusRequesters = remember(seasons) {
seasons.associateWith { FocusRequester() }
}
var initialSeasonFocusRequested by remember(item.id) { mutableStateOf(false) }
LaunchedEffect(selectedSeason, seasons) {
val requester = seasonFocusRequesters[selectedSeason] ?: return@LaunchedEffect
if (!initialSeasonFocusRequested) {
delay(40)
runCatching { requester.requestFocus() }
initialSeasonFocusRequested = true
}
}
Box(
modifier
.fillMaxSize()
.background(Color(0xFF090B0D)),
) {
BackdropLayer(item, Modifier.fillMaxSize())
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0f to Color.Black.copy(alpha = 0.20f),
0.42f to Color.Black.copy(alpha = 0.52f),
0.72f to Color(0xF5090B0D),
1f to Color(0xFF090B0D),
),
),
)
Box(
Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
0f to Color.Black.copy(alpha = 0.72f),
0.60f to Color.Black.copy(alpha = 0.18f),
1f to Color.Transparent,
),
),
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(start = 64.dp, end = 52.dp, top = 42.dp, bottom = 34.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(Modifier.fillMaxWidth(0.70f)) {
Text(
"SERIES",
color = SeriesGreen,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.4.sp,
)
Text(
item.name,
color = Color.White,
fontSize = 36.sp,
lineHeight = 40.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val facts = listOfNotNull(
item.productionYear?.toString(),
seasons.size.takeIf { it > 0 }?.let { count ->
"$count ${if (count == 1) "Season" else "Seasons"}"
},
item.officialRating,
item.genres.take(2).joinToString(" · ").takeIf(String::isNotBlank),
)
if (facts.isNotEmpty()) {
Text(
facts.joinToString(""),
color = SeriesMuted,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = SeriesMuted,
fontSize = 15.sp,
lineHeight = 20.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 8.dp),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = { onToggleFavorite(item, !item.isFavorite) }) {
Text(if (item.isFavorite) "Remove favourite" else "Add favourite")
}
Button(onClick = onClose) { Text("Close") }
}
}
Spacer(Modifier.height(15.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
SeriesDetailTabs(
selected = selectedSection,
episodeCount = episodes?.size,
castCount = item.cast.size,
onSelected = { selectedSectionKey = it.key },
)
if (selectedSection == SeriesDetailSection.EPISODES && seasons.isNotEmpty()) {
Box(
Modifier
.padding(horizontal = 16.dp)
.width(1.dp)
.height(30.dp)
.background(Color.White.copy(alpha = 0.12f)),
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(9.dp),
modifier = Modifier.weight(1f),
) {
items(seasons, key = { it }) { season ->
SeasonChip(
season = season,
selected = season == selectedSeason,
focusRequester = seasonFocusRequesters.getValue(season),
episodeFocusRequester = firstEpisode,
onClick = { selectedSeason = season },
)
}
}
}
}
Spacer(Modifier.height(8.dp))
when {
selectedSection == SeriesDetailSection.CAST -> {
if (item.cast.isEmpty()) {
Text(
"Cast information is not available for this show.",
color = SeriesMuted,
fontSize = 15.sp,
modifier = Modifier.padding(top = 24.dp),
)
} else {
CastRail(
people = item.cast,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
showTitle = false,
)
}
}
episodes == null -> {
Text(
"Loading seasons…",
color = SeriesQuiet,
fontSize = 15.sp,
modifier = Modifier.padding(top = 24.dp),
)
}
loadFailed -> {
Text(
"Episodes are temporarily unavailable.",
color = SeriesMuted,
fontSize = 15.sp,
modifier = Modifier.padding(top = 24.dp),
)
}
seasons.isEmpty() -> {
Text(
"No episodes are available for this show.",
color = SeriesMuted,
fontSize = 15.sp,
modifier = Modifier.padding(top = 24.dp),
)
}
else -> {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 2.dp,
end = 42.dp,
top = 7.dp,
bottom = 8.dp,
),
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
items(seasonEpisodes, key = BaseItem::id) { episode ->
EpisodeCard(
episode = episode,
onClick = { onPlay(episode) },
seasonFocusRequester = seasonFocusRequesters[selectedSeason]
?: FocusRequester.Default,
modifier = if (episode.id == seasonEpisodes.first().id) {
Modifier
.focusRequester(firstEpisode)
} else {
Modifier
},
)
}
}
}
}
}
}
}
internal enum class SeriesDetailSection(val key: String) {
EPISODES("episodes"),
CAST("cast"),
}
internal fun seriesDetailSection(key: String): SeriesDetailSection =
SeriesDetailSection.entries.firstOrNull { it.key == key } ?: SeriesDetailSection.EPISODES
@Composable
private fun SeriesDetailTabs(
selected: SeriesDetailSection,
episodeCount: Int?,
castCount: Int,
onSelected: (SeriesDetailSection) -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SeriesDetailTab(
label = buildString {
append("Episodes")
episodeCount?.takeIf { it > 0 }?.let { append(" $it") }
},
selected = selected == SeriesDetailSection.EPISODES,
onClick = { onSelected(SeriesDetailSection.EPISODES) },
)
SeriesDetailTab(
label = buildString {
append("Cast")
castCount.takeIf { it > 0 }?.let { append(" $it") }
},
selected = selected == SeriesDetailSection.CAST,
onClick = { onSelected(SeriesDetailSection.CAST) },
)
}
}
@Composable
private fun SeriesDetailTab(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
val shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
val background = when {
focused -> Color.White
selected -> SeriesGreen.copy(alpha = 0.18f)
else -> Color.White.copy(alpha = 0.055f)
}
val textColor = if (focused) Color.Black else if (selected) Color.White else SeriesQuiet
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(shape)
.background(background)
.border(
1.dp,
when {
focused -> Color.White
selected -> SeriesGreen.copy(alpha = 0.72f)
else -> Color.White.copy(alpha = 0.10f)
},
shape,
)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
.padding(start = 21.dp, top = 10.dp, end = 21.dp, bottom = 8.dp),
) {
Text(
label,
color = textColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
Box(
Modifier
.padding(top = 7.dp)
.width(34.dp)
.height(3.dp)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(2.dp))
.background(if (selected && !focused) SeriesGreen else Color.Transparent),
)
}
}
@Composable
private fun SeasonChip(
season: Int,
selected: Boolean,
focusRequester: FocusRequester,
episodeFocusRequester: FocusRequester,
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
val shape = androidx.compose.foundation.shape.RoundedCornerShape(7.dp)
Box(
modifier = Modifier
.clip(shape)
.background(
when {
focused -> Color.White
selected -> SeriesGreen
else -> Color(0xCC20252A)
},
)
.border(1.dp, Color.White.copy(alpha = if (selected) 0.28f else 0.12f), shape)
.focusRequester(focusRequester)
.focusProperties { down = episodeFocusRequester }
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
.padding(horizontal = 17.dp, vertical = 9.dp),
) {
Text(
if (season == 0) "Specials" else "Season $season",
color = if (focused) Color.Black else Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun EpisodeCard(
episode: BaseItem,
onClick: () -> Unit,
seasonFocusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
val repository = ServiceLocator.repository
var focused by remember { mutableStateOf(false) }
val imageUrl = remember(episode.id) {
repository.primaryUrl(episode, 360) ?: repository.backdropUrl(episode, 360)
}
val shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
val progress = remember(episode.userData, episode.runTimeTicks) {
val runtime = episode.runTimeTicks ?: 0L
if (runtime > 0L) {
((episode.userData?.playbackPositionTicks ?: 0L).toFloat() / runtime).coerceIn(0f, 1f)
} else {
0f
}
}
val scale by animateFloatAsState(
targetValue = if (focused) 1.045f else 1f,
animationSpec = tween(110),
label = "episode-card-focus",
)
Column(
modifier = modifier
.width(252.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.zIndex(if (focused) 1f else 0f)
.focusProperties { up = seasonFocusRequester }
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
.padding(bottom = 4.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(shape)
.background(Color(0xFF20252A)),
) {
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
Box(
Modifier
.fillMaxSize()
.border(
width = if (focused) 3.dp else 1.dp,
color = if (focused) Color.White else Color.White.copy(alpha = 0.10f),
shape = shape,
),
)
if (progress > 0f) {
Box(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(4.dp)
.background(Color.Black.copy(alpha = 0.65f)),
) {
Box(
Modifier
.fillMaxWidth(progress)
.height(4.dp)
.background(SeriesGreen),
)
}
}
if (episode.userData?.played == true) {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Watched",
tint = SeriesGreen,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(9.dp)
.size(20.dp),
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 9.dp),
) {
Text(
text = listOfNotNull(
episode.indexNumber?.let { "$it." },
episode.name,
).joinToString(" "),
color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
episode.runtimeMinutes?.let {
Text(
"${it}m",
color = SeriesQuiet,
fontSize = 12.sp,
modifier = Modifier.padding(start = 8.dp),
)
}
}
Text(
episode.overview?.takeIf(String::isNotBlank) ?: "No episode description available.",
color = if (focused) SeriesMuted else SeriesQuiet.copy(alpha = 0.72f),
fontSize = 12.sp,
lineHeight = 16.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 5.dp),
)
}
}
internal val seriesEpisodeComparator =
compareBy<BaseItem>({ it.parentIndexNumber ?: Int.MAX_VALUE }, { it.indexNumber ?: Int.MAX_VALUE }, { it.name })
internal fun availableSeasons(episodes: List<BaseItem>): List<Int> =
episodes.mapNotNull(BaseItem::parentIndexNumber).distinct().sorted()
internal fun episodesForSeason(episodes: List<BaseItem>, season: Int?): List<BaseItem> =
if (season == null) emptyList()
else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator)
internal fun defaultSeason(episodes: List<BaseItem>): Int? =
episodes.firstOrNull {
it.userData?.played != true || ((it.userData?.playbackPositionTicks ?: 0L) > 0L)
}?.parentIndexNumber ?: availableSeasons(episodes).firstOrNull()
@@ -0,0 +1,351 @@
package com.ponzischeme89.memby.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.ServiceAlert
import kotlin.math.ceil
private val AlertAccent = Color(0xFF52B54B)
private val AlertTitle = Color(0xFFF2F5F7)
private val AlertBody = Color(0xFFC3CBD2)
/** Height of the bar itself, before the rule and fade that blend it into the screen. */
private val BannerHeight = 104.dp
/**
* Broadcast-style overscan inset. TVs crop the edges of the picture by a few percent, so
* nothing meaningful may sit closer than this to the frame.
*/
private val SafeAreaHorizontal = 48.dp
/**
* A full-width bar that drops in over the top of the screen to say an episode has aired —
* the shape a broadcaster uses for an in-programme notice, rather than a corner toast.
*
* Deliberately not focusable and not dismissable by D-pad: it must never steal focus from
* a row mid-browse, so it times itself out instead of asking the viewer to act. It spans
* the navigation rail as well, because for its few seconds it is the top layer of the
* screen; it draws above the rows but below the update prompt, which is the one thing
* allowed to own the whole display.
*/
@Composable
fun ServiceAlertBanner(suppressed: Boolean, modifier: Modifier = Modifier) {
// The alert is collected *here* rather than passed down from the home composable.
// Read one scope up and every arriving alert would recompose the entire launcher
// body; read here and it recomposes a bar that is usually not even on screen.
val current by ServiceLocator.maintenance.alert.collectAsStateWithLifecycle()
val alert = current?.takeUnless { suppressed }
// Tell the monitor the moment this is really on screen. Until it hears that, the
// alert is only offered — it starts no dismissal timer and persists nothing — so an
// alert arriving behind the screensaver or another app is not silently used up.
LaunchedEffect(alert?.id) {
alert?.id?.let { ServiceLocator.maintenance.alertShown(it) }
}
// The exit animation still needs something to draw, so hold the last alert until the
// slide-out has finished with it.
var lastAlert by remember { mutableStateOf<ServiceAlert?>(null) }
if (alert != null) lastAlert = alert
AnimatedVisibility(
visible = alert != null,
// In from above the frame, out the same way. Slower arriving than leaving:
// showing up should be noticed, going away should not.
enter = slideInVertically(tween(420, easing = FastOutSlowInEasing)) { -it } +
fadeIn(tween(260)),
exit = slideOutVertically(tween(320, easing = FastOutSlowInEasing)) { -it } +
fadeOut(tween(220)),
modifier = modifier.zIndex(8f),
) {
lastAlert?.let { AlertBanner(it) }
}
}
// Internal so the screenshot test can render the bar on its own, without the drop-in
// wrapper around it.
@Composable
internal fun AlertBanner(
alert: ServiceAlert,
visibleMillis: Long = MaintenanceMonitor.ALERT_VISIBLE_MS,
) {
// Keyed on the alert id so a second banner arriving restarts the countdown rather
// than inheriting whatever was left of the first one's.
val remaining = remember(alert.id) { Animatable(1f) }
LaunchedEffect(alert.id) {
remaining.animateTo(0f, tween(visibleMillis.toInt(), easing = LinearEasing))
}
// The ring's fraction is handed down as a lambda and the seconds as derived state, so
// ten seconds of animation costs ~10 recompositions of one number instead of ~600 of
// this whole bar. On a weak TV box that difference is the feature's entire cost.
val secondsLeft = remember(alert.id, visibleMillis) {
derivedStateOf { ceil(remaining.value * visibleMillis / 1000f).toInt() }
}
Column(Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(BannerHeight)
.background(
// Darkest at the left where the text sits, easing off to the right so
// the artwork behind the banner still shows through.
Brush.horizontalGradient(
0f to Color(0xFF0E1418),
0.55f to Color(0xF20E1418),
1f to Color(0xD9121A20),
),
)
.padding(horizontal = SafeAreaHorizontal),
verticalAlignment = Alignment.CenterVertically,
) {
AlertPoster(posterUrl = alert.posterUrl)
Spacer(Modifier.width(20.dp))
Column(Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
LivePip()
Text(
"JUST AIRED",
color = AlertAccent,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
)
}
Spacer(Modifier.height(3.dp))
// Keep the alert readable at TV distance without letting it overpower
// the screen content beneath it.
Text(
alert.title,
color = AlertTitle,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
alert.message,
color = AlertBody,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(24.dp))
CountdownRing(fraction = { remaining.value }, secondsLeft = secondsLeft)
}
// An accent rule under the bar, then a short fade: without them the banner ends
// on a hard line across the artwork, which reads as a rendering seam.
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.background(
Brush.horizontalGradient(
listOf(AlertAccent, AlertAccent.copy(alpha = 0.35f), Color.Transparent),
),
),
)
Box(
Modifier
.fillMaxWidth()
.height(22.dp)
.background(
Brush.verticalGradient(listOf(Color(0x99000000), Color.Transparent)),
),
)
}
}
/**
* A ring that empties as the banner's time runs out, with the seconds left inside it.
*
* This exists to answer "is it about to go, or did it stick?" — the banner cannot be
* dismissed by remote, so the one thing the viewer can usefully know is how long they
* have to read it. Drawn in a single Canvas: an arc and a track are cheaper than any
* progress component, and this animates every frame for ten seconds.
*
* [fraction] is a lambda and [secondsLeft] a [State] on purpose. Read either one in a
* composable body and every frame recomposes; read them inside the draw lambda and in a
* derived state, and the sweep costs a redraw only.
*/
@Composable
private fun CountdownRing(fraction: () -> Float, secondsLeft: State<Int>) {
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) {
val stroke = 3.dp.toPx()
val inset = stroke / 2f
val arcSize = Size(size.width - stroke, size.height - stroke)
drawArc(
color = Color.White.copy(alpha = 0.12f),
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
topLeft = Offset(inset, inset),
size = arcSize,
style = Stroke(width = stroke, cap = StrokeCap.Round),
)
drawArc(
color = AlertAccent,
// Twelve o'clock, unwinding clockwise: the direction a clock hand sweeps,
// so "less arc left" reads as "less time left" without a legend.
startAngle = -90f,
sweepAngle = 360f * fraction().coerceIn(0f, 1f),
useCenter = false,
topLeft = Offset(inset, inset),
size = arcSize,
style = Stroke(width = stroke, cap = StrokeCap.Round),
)
}
Text(
secondsLeft.value.coerceAtLeast(0).toString(),
color = AlertBody,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
)
}
}
/** Poster when the server named one; a quiet accent tile when it did not. */
@Composable
private fun AlertPoster(posterUrl: String?) {
val shape = RoundedCornerShape(6.dp)
Box(
modifier = Modifier
.width(50.dp)
.height(74.dp)
.clip(shape)
.background(Color(0xFF18222B)),
contentAlignment = Alignment.Center,
) {
if (posterUrl != null) {
AsyncImage(
model = posterUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
Modifier
.size(20.dp)
.clip(CircleShape)
.background(AlertAccent.copy(alpha = 0.30f)),
)
}
}
}
/** A slow pulse — enough motion to read as "news", cheap enough for a TV GPU. */
@Composable
private fun LivePip() {
val alpha = androidx.compose.animation.core.rememberInfiniteTransition(label = "alert-pip")
.animateFloat(
initialValue = 0.35f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
tween(1_400, easing = LinearEasing),
RepeatMode.Reverse,
),
label = "alert-pip-alpha",
)
// Deliberately not `by`: the alpha is read inside the draw lambda, so the pulse
// repaints eight dp of circle rather than recomposing the row that holds it.
Canvas(Modifier.size(8.dp)) {
drawCircle(color = AlertAccent.copy(alpha = alpha.value))
}
}
// Previews render the bar directly rather than through ServiceAlertBanner: the wrapper's
// whole job is the drop-in, and a still frame of an animation in progress says nothing.
// Posters are left null on purpose — the preview has no network, so this is also the
// fallback tile being checked.
@TvPreview
@Composable
private fun ServiceAlertBannerPreview() {
PreviewSurface(alignment = Alignment.TopCenter) {
AlertBanner(
ServiceAlert(
id = "sonarr:7:42:aired",
title = "Northbound",
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
posterUrl = null,
),
)
}
}
/** Long strings are common; the single-line ellipsis is the part worth eyeballing. */
@TvPreview
@Composable
private fun ServiceAlertBannerLongTitlePreview() {
PreviewSurface(alignment = Alignment.TopCenter) {
AlertBanner(
ServiceAlert(
id = "sonarr:9:88:aired",
title = "A Very Long Programme Title That Will Not Fit On One Line At All",
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
"aired at 10:30 PM and is downloading now.",
posterUrl = null,
),
)
}
}
@@ -0,0 +1,229 @@
package com.ponzischeme89.memby.ui.detail
import com.ponzischeme89.memby.data.model.BaseItem
import java.util.Locale
/**
* Everything the detail pages need to *say*, as pure functions.
*
* None of this touches Compose, the repository or Android, so the whole vocabulary of the
* two screens — which season opens first, what the primary button is called, which
* technical facts are worth a line — is unit-testable without a device. The composables in
* this package are deliberately thin over these.
*/
/** Emby stores durations and positions as 100-ns ticks. */
private const val TICKS_PER_MINUTE = 600_000_000L
/** "2h 14m", "47m". Never "0m" — callers pass a positive runtime or nothing. */
fun formatRuntime(minutes: Int): String {
val hours = minutes / 60
val remainder = minutes % 60
return when {
hours > 0 && remainder > 0 -> "${hours}h ${remainder}m"
hours > 0 -> "${hours}h"
else -> "${remainder}m"
}
}
/** Position on the playhead, in the same wording as a runtime. */
fun formatPosition(ticks: Long): String =
formatRuntime((ticks / TICKS_PER_MINUTE).toInt().coerceAtLeast(0))
val BaseItem.resumeTicks: Long get() = userData?.playbackPositionTicks ?: 0L
val BaseItem.isPlayed: Boolean get() = userData?.played == true
/** True when there is a meaningful position to resume from. */
val BaseItem.isResumable: Boolean get() = resumeTicks > 0L
/** 0f..1f through the item, or 0f when either end is unknown. */
fun playbackProgress(item: BaseItem): Float {
val runtime = item.runTimeTicks ?: 0L
if (runtime <= 0L) return 0f
return (item.resumeTicks.toFloat() / runtime).coerceIn(0f, 1f)
}
/** "1h 12m left", or null when the remainder cannot be worked out. */
fun remainingLabel(item: BaseItem): String? {
val runtime = item.runTimeTicks ?: return null
val left = runtime - item.resumeTicks
if (item.resumeTicks <= 0L || left <= TICKS_PER_MINUTE) return null
return "${formatPosition(left)} left"
}
/** The headline row under a movie title: year, runtime, certificate, score, genres. */
fun movieFacts(item: BaseItem): List<String> = buildList {
item.productionYear?.let { add(it.toString()) }
item.runtimeMinutes?.let { add(formatRuntime(it)) }
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
item.communityRating?.let { add("${String.format(Locale.US, "%.1f", it)}") }
item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank)?.let(::add)
}
/** The same row for a series, where seasons replace runtime. */
fun seriesFacts(item: BaseItem, seasonCount: Int): List<String> = buildList {
item.productionYear?.let { add(it.toString()) }
if (seasonCount > 0) add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
item.communityRating?.let { add("${String.format(Locale.US, "%.1f", it)}") }
item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank)?.let(::add)
}
/** A label/value pair for the quiet technical area. */
data class TechnicalSpec(val label: String, val value: String)
/**
* Resolution, codecs and studio — the things a viewer checks before settling in, kept out
* of the headline because none of them decide what to watch.
*/
fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
val video = item.mediaStreams.firstOrNull { it.type.equals("Video", ignoreCase = true) }
val audio = item.mediaStreams.firstOrNull { it.type.equals("Audio", ignoreCase = true) }
val subtitles = item.mediaStreams.count { it.type.equals("Subtitle", ignoreCase = true) }
return buildList {
video?.let { stream ->
val width = stream.width
val height = stream.height
if (width != null && height != null) {
add(TechnicalSpec("Video", "$width × $height${resolutionSuffix(width)}"))
}
listOfNotNull(
stream.codec?.uppercase(Locale.US),
dynamicRangeLabel(stream.videoRange, stream.videoRangeType, stream.title),
).takeIf(List<String>::isNotEmpty)?.let {
add(TechnicalSpec("Codec", it.joinToString(" · ")))
}
}
audio?.let { stream ->
listOfNotNull(
stream.codec?.uppercase(Locale.US),
stream.channels?.let(::channelLabel),
stream.language?.takeIf(String::isNotBlank),
).takeIf(List<String>::isNotEmpty)?.let {
add(TechnicalSpec("Audio", it.joinToString(" · ")))
}
}
if (subtitles > 0) {
add(TechnicalSpec("Subtitles", "$subtitles ${if (subtitles == 1) "track" else "tracks"}"))
}
item.studios.map { it.name }.filter(String::isNotBlank).take(2)
.takeIf(List<String>::isNotEmpty)
?.let { add(TechnicalSpec("Studio", it.joinToString(", "))) }
}
}
private fun resolutionSuffix(width: Int): String = when {
width >= 3_400 -> " (4K)"
width >= 2_500 -> " (1440p)"
width >= 1_800 -> " (1080p)"
width >= 1_200 -> " (720p)"
else -> ""
}
private fun dynamicRangeLabel(range: String?, rangeType: String?, title: String?): String? {
val haystack = listOfNotNull(range, rangeType, title).joinToString(" ").lowercase(Locale.US)
return when {
"dolby vision" in haystack || "dovi" in haystack -> "Dolby Vision"
"hdr10+" in haystack -> "HDR10+"
"hdr" in haystack -> "HDR"
else -> null
}
}
private fun channelLabel(channels: Int): String = when (channels) {
1 -> "Mono"
2 -> "Stereo"
6 -> "5.1"
8 -> "7.1"
else -> "${channels}ch"
}
// ---------------------------------------------------------------------------
// Series structure
// ---------------------------------------------------------------------------
/** Running order: season, then episode, then title for anything unnumbered. */
val seriesEpisodeComparator = compareBy<BaseItem>(
{ it.parentIndexNumber ?: Int.MAX_VALUE },
{ it.indexNumber ?: Int.MAX_VALUE },
{ it.name },
)
/** Season numbers present in the library, specials (0) first. */
fun availableSeasons(episodes: List<BaseItem>): List<Int> =
episodes.mapNotNull(BaseItem::parentIndexNumber).distinct().sorted()
fun episodesForSeason(episodes: List<BaseItem>, season: Int?): List<BaseItem> =
if (season == null) emptyList()
else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator)
/**
* What the viewer should watch next: whatever is part-watched, else the first unwatched
* episode. Specials are skipped while any numbered season exists — season 0 sorts first
* but is almost never where someone is up to.
*/
fun nextEpisodeToWatch(episodes: List<BaseItem>): BaseItem? {
if (episodes.isEmpty()) return null
val ordered = episodes.sortedWith(seriesEpisodeComparator)
val hasNumberedSeason = ordered.any { (it.parentIndexNumber ?: 0) > 0 }
val candidates = if (hasNumberedSeason) {
ordered.filter { (it.parentIndexNumber ?: 0) > 0 }
} else {
ordered
}
return candidates.firstOrNull { it.isResumable && !it.isPlayed }
?: candidates.firstOrNull { !it.isPlayed }
}
/** The season to open on: the one holding [nextEpisodeToWatch], else the earliest. */
fun defaultSeason(episodes: List<BaseItem>): Int? =
nextEpisodeToWatch(episodes)?.parentIndexNumber ?: availableSeasons(episodes).firstOrNull()
/** How many episodes are still unwatched, for the series badge. */
fun unwatchedCount(episodes: List<BaseItem>): Int = episodes.count { !it.isPlayed }
/** "Season 3", or "Specials" for season 0. */
fun seasonLabel(season: Int): String = if (season == 0) "Specials" else "Season $season"
/** "S2 E4" — shorter than [BaseItem.episodeCode] and used where space is tight. */
fun episodeLabel(episode: BaseItem): String? {
val number = episode.indexNumber ?: return null
val season = episode.parentIndexNumber
return if (season != null) "S$season E$number" else "E$number"
}
/** "S2 E4 · The Crossing", falling back to whichever half is known. */
fun episodeHeadline(episode: BaseItem): String =
listOfNotNull(episodeLabel(episode), episode.name.takeIf(String::isNotBlank))
.joinToString(" · ")
/**
* The primary button. Series pass their next episode so the button can name it; a movie
* passes itself.
*/
fun primaryActionLabel(item: BaseItem, nextEpisode: BaseItem? = null): String = when {
nextEpisode != null && nextEpisode.isResumable -> "Resume ${episodeLabel(nextEpisode) ?: "episode"}"
nextEpisode != null -> "Play ${episodeLabel(nextEpisode) ?: "next episode"}"
item.isResumable -> "Resume"
else -> "Play"
}
/** The supporting line under the primary button, or null when there is nothing to add. */
fun primaryActionDetail(item: BaseItem, nextEpisode: BaseItem? = null): String? {
val target = nextEpisode ?: item
return when {
target.isResumable -> "From ${formatPosition(target.resumeTicks)}" +
(remainingLabel(target)?.let { " · $it" } ?: "")
nextEpisode != null -> nextEpisode.name.takeIf(String::isNotBlank)
else -> null
}
}
/** Kicker above the title: what kind of thing this page is about. */
fun detailKicker(item: BaseItem): String = when {
item.isMovie -> "MOVIE"
item.isSeries -> "SERIES"
item.isEpisode -> item.seriesName?.uppercase(Locale.US) ?: "EPISODE"
else -> item.type.uppercase(Locale.US).ifBlank { "LIBRARY" }
}
@@ -0,0 +1,74 @@
package com.ponzischeme89.memby.ui.player
import androidx.media3.common.PlaybackException
/**
* A viewer-facing description of a Media3 failure. Keep this independent from Activity
* state so the retry decision remains deterministic and unit-testable.
*/
internal data class PlaybackFailure(
val title: String,
val detail: String,
val canAutoRetry: Boolean,
)
internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
when (errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
-> PlaybackFailure(
title = "Connection interrupted",
detail = "Memby couldnt keep a reliable connection to the media server.",
canAutoRetry = true,
)
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
-> PlaybackFailure(
title = "Video unavailable",
detail = "The media server couldnt provide this video. It may have moved or be temporarily unavailable.",
canAutoRetry = false,
)
PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
-> PlaybackFailure(
title = "Playback blocked",
detail = "The TV is not permitted to open this stream. Check the server address and access settings.",
canAutoRetry = false,
)
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODING_FAILED,
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES,
-> PlaybackFailure(
title = "Video format not supported",
detail = "This TV couldnt decode the selected video or audio track. Try another track or a transcoded version.",
canAutoRetry = false,
)
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED,
-> PlaybackFailure(
title = "Video file couldnt be read",
detail = "The stream format is damaged or unsupported by this TV.",
canAutoRetry = false,
)
else -> PlaybackFailure(
title = "Playback stopped",
detail = "Memby hit an unexpected playback problem.",
canAutoRetry = false,
)
}
internal fun automaticRetryDelayMs(attempt: Int): Long? =
when (attempt) {
1 -> 1_000L
2 -> 3_000L
else -> null
}
@@ -0,0 +1,69 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.PlaybackSession
/**
* A persisted final check-in. WorkManager keeps this request across process death, so
* Back, task removal and background process eviction all converge on the same Emby stop.
*/
class PlaybackStopWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
ServiceLocator.init(applicationContext)
val itemId = inputData.getString(ITEM_ID).orEmpty()
if (itemId.isBlank()) return Result.failure()
val session = PlaybackSession(
itemId = itemId,
mediaSourceId = inputData.getString(MEDIA_SOURCE_ID).orEmpty().ifBlank { itemId },
playSessionId = inputData.getString(PLAY_SESSION_ID).orEmpty(),
playMethod = inputData.getString(PLAY_METHOD).orEmpty().ifBlank { "DirectPlay" },
)
return runCatching {
ServiceLocator.repository.reportPlaybackStopped(
session,
inputData.getLong(POSITION_MS, 0L),
)
}.fold(
onSuccess = { Result.success() },
onFailure = { if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure() },
)
}
companion object {
private const val ITEM_ID = "item_id"
private const val MEDIA_SOURCE_ID = "media_source_id"
private const val PLAY_SESSION_ID = "play_session_id"
private const val PLAY_METHOD = "play_method"
private const val POSITION_MS = "position_ms"
private const val MAX_RETRIES = 5
fun enqueue(context: Context, session: PlaybackSession, positionMs: Long) {
val data = Data.Builder()
.putString(ITEM_ID, session.itemId)
.putString(MEDIA_SOURCE_ID, session.mediaSourceId)
.putString(PLAY_SESSION_ID, session.playSessionId)
.putString(PLAY_METHOD, session.playMethod)
.putLong(POSITION_MS, positionMs.coerceAtLeast(0L))
.build()
val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>()
.setInputData(data)
.build()
val key = session.playSessionId.ifBlank { session.itemId }
WorkManager.getInstance(context.applicationContext).enqueueUniqueWork(
"emby-playback-stop-$key",
ExistingWorkPolicy.REPLACE,
request,
)
}
}
}
@@ -0,0 +1,905 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package com.ponzischeme89.memby.ui.search
import android.app.Activity
import android.content.Intent
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SpaceBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.utf16CodePoint
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.imageLoader
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.PosterGridCard
private val PaneBackground = Color(0xFF0C1014)
private val KeyIdle = Color(0xFF1A2129)
private val KeyFocused = Color(0xFF52B54B)
private val KeyLabel = Color(0xFFE8EDF1)
private val KeyLabelFocused = Color(0xFF06240A)
private val Heading = Color(0xFFF2F5F7)
private val Muted = Color(0xFFB7C0C8)
private val Accent = Color(0xFF52B54B)
/**
* Six columns of six, then a row of actions. Rectangular on purpose: every key has a
* neighbour directly above and below it, so D-pad movement is predictable in a way a
* QWERTY layout with ragged rows never is.
*/
private val KeyboardRows = listOf(
"ABCDEF",
"GHIJKL",
"MNOPQR",
"STUVWX",
"YZ0123",
"456789",
)
/** Left pane share of the width. Wide enough for six comfortable keys at TV distance. */
private const val KEYBOARD_PANE_FRACTION = 0.35f
/** Posters prefetched as soon as results land, so the first visible row is never blank. */
private const val PREFETCHED_POSTERS = 8
/**
* Full-screen search: keyboard on the left, results on the right, updating as you type.
*
* The two panes are the whole point — the results never disappear behind a keyboard, and
* no query is ever "submitted". Everything below is in service of that: focus that moves
* predictably between the panes, and a result pane that updates without flashing.
*/
@Composable
fun SearchScreen(
navigationFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
discoveryItems: List<BaseItem>,
returnFocusItemId: String?,
returnFocusRequester: FocusRequester,
initialQuery: String? = null,
onInitialQueryConsumed: () -> Unit = {},
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
onContentFocused: () -> Unit,
onExit: () -> Unit,
modifier: Modifier = Modifier,
) {
val repository = ServiceLocator.repository
val viewModel: SearchViewModel = viewModel(
factory = remember(repository) { SearchViewModelFactory(repository) },
)
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(discoveryItems) { viewModel.setDiscoveryItems(discoveryItems) }
LaunchedEffect(initialQuery) {
initialQuery?.takeIf { it.isNotBlank() }?.let {
viewModel.onQueryChanged(it)
onInitialQueryConsumed()
}
}
val resultsEntry = remember { FocusRequester() }
// The rail and the screen agree on one entry target: Search opens on the keyboard,
// just as browse destinations open on their primary content action.
val keyboardEntry = contentFocusRequester
// Where "left out of the results" lands. It follows the last key used, so coming back
// returns the viewer to where they were typing rather than to a fixed corner.
val keyboardReturn = remember { FocusRequester() }
var lastKeyIndex by remember { mutableIntStateOf(0) }
var focusInResults by remember { mutableStateOf(false) }
val hasResultsTarget = when {
state.errorMessage != null && state.results.isEmpty() -> true
state.isDiscovery -> discoveryItems.isNotEmpty() ||
state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE }
else -> state.results.isNotEmpty()
}
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
BackHandler {
when {
// Results → keyboard → clear → leave. Each press does one obvious thing, and
// none of them can loop back to the previous state.
focusInResults -> runCatching { keyboardReturn.requestFocus() }
.onFailure { runCatching { keyboardEntry.requestFocus() } }
state.query.isNotEmpty() -> viewModel.clearQuery()
else -> onExit()
}
}
Row(
modifier = modifier
.fillMaxSize()
// A USB keyboard, or a phone remote app sending key events, feeds exactly the
// same query state as the on-screen keys. Only printable characters and
// backspace are consumed; D-pad and Back must fall through untouched.
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
val code = event.utf16CodePoint
when {
code == BACKSPACE_CODE -> {
viewModel.backspace(); true
}
code >= FIRST_PRINTABLE_CODE -> {
viewModel.appendToQuery(code.toChar().toString()); true
}
else -> false
}
},
) {
SearchPane(
state = state,
navigationFocusRequester = navigationFocusRequester,
resultsEntry = resultsEntry,
keyboardEntry = keyboardEntry,
keyboardReturn = keyboardReturn,
lastKeyIndex = lastKeyIndex,
hasResultsTarget = hasResultsTarget,
onKeyFocused = { index ->
lastKeyIndex = index
focusInResults = false
onContentFocused()
},
onCharacter = viewModel::appendToQuery,
onBackspace = viewModel::backspace,
onClear = viewModel::clearQuery,
onVoiceResult = viewModel::onQueryChanged,
modifier = Modifier
.fillMaxWidth(KEYBOARD_PANE_FRACTION)
.fillMaxHeight(),
)
ResultsPane(
state = state,
resultsEntry = resultsEntry,
keyboardReturn = keyboardReturn,
returnFocusItemId = returnFocusItemId,
returnFocusRequester = returnFocusRequester,
discoveryItems = discoveryItems,
onItemFocused = { item ->
focusInResults = true
onContentFocused()
onItemFocused(item)
},
onItemSelected = onItemSelected,
onRetry = viewModel::retry,
onSuggestionSelected = viewModel::onQueryChanged,
modifier = Modifier.fillMaxHeight(),
)
}
}
@Composable
private fun SearchPane(
state: SearchUiState,
navigationFocusRequester: FocusRequester,
resultsEntry: FocusRequester,
keyboardEntry: FocusRequester,
keyboardReturn: FocusRequester,
lastKeyIndex: Int,
hasResultsTarget: Boolean,
onKeyFocused: (Int) -> Unit,
onCharacter: (String) -> Unit,
onBackspace: () -> Unit,
onClear: () -> Unit,
onVoiceResult: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.background(PaneBackground)
.padding(start = 28.dp, end = 20.dp, top = 24.dp, bottom = 16.dp),
) {
Text("Search", color = Heading, fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(10.dp))
QueryField(
query = state.query,
loading = state.isLoading,
onVoiceResult = onVoiceResult,
)
Spacer(Modifier.height(14.dp))
TvKeyboard(
navigationFocusRequester = navigationFocusRequester,
resultsEntry = resultsEntry,
keyboardEntry = keyboardEntry,
keyboardReturn = keyboardReturn,
lastKeyIndex = lastKeyIndex,
hasResultsTarget = hasResultsTarget,
onKeyFocused = onKeyFocused,
onCharacter = onCharacter,
onBackspace = onBackspace,
onClear = onClear,
)
}
}
/**
* The query as typed, plus the two things that belong beside it: a quiet progress dot
* while a search is in flight, and voice input when the device offers it.
*/
@Composable
private fun QueryField(
query: String,
loading: Boolean,
onVoiceResult: (String) -> Unit,
) {
val context = LocalContext.current
val voiceAvailable = remember { SpeechRecognizer.isRecognitionAvailable(context) }
val voiceLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val spoken = result.data
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
?.firstOrNull()
?.trim()
// Replaces rather than appends: dictating a title after typing half of another
// one almost always means "no, this instead".
if (!spoken.isNullOrEmpty()) onVoiceResult(spoken)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFF151C23))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Search,
contentDescription = null,
tint = if (query.isEmpty()) Muted else Accent,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(10.dp))
Text(
text = query.ifEmpty { "Search movies, shows and episodes" },
color = if (query.isEmpty()) Muted else Heading,
// Fixed size rather than shrinking as the query grows: readable at three
// metres matters more than fitting a long query on one line.
fontSize = if (query.isEmpty()) 15.sp else 17.sp,
fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
maxLines = 1,
// A long query scrolls off the *start*, so the letters just typed stay visible.
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (loading) {
Spacer(Modifier.width(8.dp))
LoadingDot()
}
if (voiceAvailable) {
Spacer(Modifier.width(8.dp))
FocusScaleContainer(
onFocused = {},
onClick = {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
)
putExtra(RecognizerIntent.EXTRA_PROMPT, "Say a title")
}
// A device can advertise recognition and still have nothing to
// launch; failing silently beats crashing the search screen.
runCatching { voiceLauncher.launch(intent) }
},
contentDescription = "Search by voice",
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
) { focused ->
Icon(
Icons.Default.Mic,
contentDescription = null,
tint = if (focused) KeyLabelFocused else KeyLabel,
modifier = Modifier
.background(if (focused) KeyFocused else KeyIdle)
.padding(6.dp)
.size(18.dp),
)
}
}
}
}
@Composable
private fun TvKeyboard(
navigationFocusRequester: FocusRequester,
resultsEntry: FocusRequester,
keyboardEntry: FocusRequester,
keyboardReturn: FocusRequester,
lastKeyIndex: Int,
hasResultsTarget: Boolean,
onKeyFocused: (Int) -> Unit,
onCharacter: (String) -> Unit,
onBackspace: () -> Unit,
onClear: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
KeyboardRows.forEachIndexed { rowIndex, row ->
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
row.forEachIndexed { columnIndex, character ->
val index = rowIndex * KEYBOARD_COLUMNS + columnIndex
KeyboardKey(
label = character.toString(),
contentDescription = "Type ${character}",
onClick = { onCharacter(character.toString()) },
onFocused = { onKeyFocused(index) },
modifier = Modifier
.weight(1f)
// Explicit edges. Left of the first column is the navigation
// rail, right of the last is the results grid: focus can
// always get out of this pane, and never falls off it.
.focusProperties {
if (columnIndex == 0) left = navigationFocusRequester
if (columnIndex == KEYBOARD_COLUMNS - 1) {
right = if (hasResultsTarget) resultsEntry else FocusRequester.Cancel
}
}
.then(
if (index == 0) Modifier.focusRequester(keyboardEntry) else Modifier,
)
.then(
if (index == lastKeyIndex) {
Modifier.focusRequester(keyboardReturn)
} else {
Modifier
},
),
)
}
}
}
Spacer(Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ActionKey(
icon = Icons.Default.SpaceBar,
label = "Space",
contentDescription = "Insert a space",
onClick = { onCharacter(" ") },
onFocused = { onKeyFocused(ACTION_ROW_INDEX) },
modifier = Modifier
.weight(2f)
.focusProperties { left = navigationFocusRequester }
.then(
if (lastKeyIndex == ACTION_ROW_INDEX) {
Modifier.focusRequester(keyboardReturn)
} else {
Modifier
},
),
)
ActionKey(
icon = Icons.AutoMirrored.Filled.Backspace,
label = "Delete",
contentDescription = "Delete the last character",
onClick = onBackspace,
onFocused = { onKeyFocused(ACTION_ROW_INDEX + 1) },
modifier = Modifier
.weight(2f)
.then(
if (lastKeyIndex == ACTION_ROW_INDEX + 1) {
Modifier.focusRequester(keyboardReturn)
} else {
Modifier
},
),
)
ActionKey(
icon = Icons.Default.Close,
label = "Clear",
contentDescription = "Clear the whole query",
onClick = onClear,
onFocused = { onKeyFocused(ACTION_ROW_INDEX + 2) },
modifier = Modifier
.weight(2f)
.focusProperties {
right = if (hasResultsTarget) resultsEntry else FocusRequester.Cancel
}
.then(
if (lastKeyIndex == ACTION_ROW_INDEX + 2) {
Modifier.focusRequester(keyboardReturn)
} else {
Modifier
},
),
)
}
}
}
@Composable
private fun KeyboardKey(
label: String,
contentDescription: String,
onClick: () -> Unit,
onFocused: () -> Unit,
modifier: Modifier = Modifier,
) {
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
contentDescription = contentDescription,
modifier = modifier.clip(RoundedCornerShape(8.dp)),
) { focused ->
Box(
Modifier
.fillMaxWidth()
// 44dp of target at six columns across a third of a 1080p screen: large
// enough to hit reliably while glancing at the results, not the keyboard.
.height(36.dp)
.background(if (focused) KeyFocused else KeyIdle),
contentAlignment = Alignment.Center,
) {
Text(
label,
color = if (focused) KeyLabelFocused else KeyLabel,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@Composable
private fun ActionKey(
icon: ImageVector,
label: String,
contentDescription: String,
onClick: () -> Unit,
onFocused: () -> Unit,
modifier: Modifier = Modifier,
) {
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
contentDescription = contentDescription,
modifier = modifier.clip(RoundedCornerShape(8.dp)),
) { focused ->
Row(
Modifier
.fillMaxWidth()
.height(36.dp)
.background(if (focused) KeyFocused else KeyIdle),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
icon,
contentDescription = null,
tint = if (focused) KeyLabelFocused else KeyLabel,
modifier = Modifier.size(17.dp),
)
Spacer(Modifier.width(7.dp))
Text(
label,
color = if (focused) KeyLabelFocused else KeyLabel,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
}
}
}
/** A quiet pulse beside the query. Deliberately not a full-screen spinner. */
@Composable
private fun LoadingDot() {
val alpha = rememberInfiniteTransition(label = "search-loading").animateFloat(
initialValue = 0.25f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(620), RepeatMode.Reverse),
label = "search-loading-alpha",
)
// Read in the draw lambda: a spinner beside the query must not recompose the pane
// that holds the keyboard.
Box(
Modifier
.size(9.dp)
.drawBehind { drawCircle(color = Accent.copy(alpha = alpha.value)) }
.semantics { contentDescription = "Searching" },
)
}
@Composable
private fun ResultsPane(
state: SearchUiState,
resultsEntry: FocusRequester,
keyboardReturn: FocusRequester,
returnFocusItemId: String?,
returnFocusRequester: FocusRequester,
discoveryItems: List<BaseItem>,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
onRetry: () -> Unit,
onSuggestionSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(
modifier = modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 32.dp, top = 28.dp, bottom = 12.dp),
) {
val columns = if (maxWidth > 760.dp) 5 else 4
val cardWidth = ((maxWidth - CARD_SPACING * (columns - 1)) / columns)
.coerceIn(120.dp, 200.dp)
// Discovery, results, error and "no matches" all share this pane. Only the
// heading and the item source change, so the grid never unmounts and remounts.
val showingDiscovery = state.isDiscovery
val items = if (showingDiscovery) discoveryItems else state.results
Column(Modifier.fillMaxSize()) {
ResultsHeading(state = state, showingDiscovery = showingDiscovery)
val genres = state.suggestions.filter { it.kind == SearchSuggestion.Kind.GENRE }
val recent = state.suggestions.filter { it.kind == SearchSuggestion.Kind.RECENT }
if (showingDiscovery && genres.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
GenreTiles(
genres = genres,
resultsEntry = resultsEntry,
keyboardReturn = keyboardReturn,
onSelected = onSuggestionSelected,
)
}
if (showingDiscovery && recent.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(
"RECENT SEARCHES",
color = Muted,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.8.sp,
)
Spacer(Modifier.height(4.dp))
SuggestionChips(
suggestions = recent,
resultsEntry = null,
keyboardReturn = keyboardReturn,
onSelected = onSuggestionSelected,
)
}
Spacer(Modifier.height(12.dp))
when {
state.errorMessage != null && state.results.isEmpty() -> SearchError(
message = state.errorMessage,
onRetry = onRetry,
resultsEntry = resultsEntry,
keyboardReturn = keyboardReturn,
)
items.isEmpty() -> SearchEmptyMessage(state = state, showingDiscovery = showingDiscovery)
else -> ResultsGrid(
items = items,
columns = columns,
cardWidth = cardWidth,
resultsEntry = resultsEntry,
keyboardReturn = keyboardReturn,
returnFocusItemId = returnFocusItemId,
returnFocusRequester = returnFocusRequester,
onItemFocused = onItemFocused,
onItemSelected = onItemSelected,
)
}
}
}
}
@Composable
private fun ResultsHeading(state: SearchUiState, showingDiscovery: Boolean) {
val title = when {
showingDiscovery -> "Browse your library"
state.isEmptyResult -> "Search results — no matches"
else -> "Search results for “${state.query.trim()}"
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
title,
color = Heading,
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (!showingDiscovery && state.results.isNotEmpty()) {
Spacer(Modifier.width(10.dp))
Text("${state.results.size}", color = Muted, fontSize = 16.sp)
}
}
}
@Composable
private fun ResultsGrid(
items: List<BaseItem>,
columns: Int,
cardWidth: Dp,
resultsEntry: FocusRequester,
keyboardReturn: FocusRequester,
returnFocusItemId: String?,
returnFocusRequester: FocusRequester,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
) {
val context = LocalContext.current
val density = LocalDensity.current
val gridState = rememberLazyGridState()
// Warm the first screenful so the grid does not fill in card by card. Keyed on the
// ids rather than the list, so an unchanged result set never re-fetches.
val prefetchKey = remember(items) { items.take(PREFETCHED_POSTERS).joinToString("|") { it.id } }
LaunchedEffect(prefetchKey, cardWidth) {
val repo = ServiceLocator.repository
val widthPx = with(density) { cardWidth.roundToPx() }.coerceIn(180, 720)
items.take(PREFETCHED_POSTERS).forEach { item ->
val url = repo.primaryUrl(item, widthPx) ?: repo.backdropUrl(item, widthPx)
?: return@forEach
context.imageLoader.execute(
ImageRequest.Builder(context)
.data(url)
.size(widthPx, (widthPx * 3f / 2f).toInt())
.allowHardware(true)
.crossfade(false)
.build(),
)
}
}
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = gridState,
horizontalArrangement = Arrangement.spacedBy(CARD_SPACING),
verticalArrangement = Arrangement.spacedBy(18.dp),
modifier = Modifier
.fillMaxSize(),
) {
// itemsIndexed, not items + indexOf: BaseItem is a data class, so indexOf would
// run a deep equals per card per composition on the app's hottest new screen.
itemsIndexed(items, key = { _, item -> item.id }, contentType = { _, _ -> "search-result" }) { index, item ->
PosterGridCard(
item = item,
width = cardWidth,
onFocused = { onItemFocused(item) },
onClick = { onItemSelected(item) },
onLongClick = { onItemSelected(item) },
modifier = Modifier
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
.then(
if (item.id == returnFocusItemId) {
Modifier.focusRequester(returnFocusRequester)
} else {
Modifier
},
)
// Leftmost column goes back to the keyboard rather than nowhere.
.focusProperties {
if (index % columns == 0) left = keyboardReturn
},
)
}
}
}
private val GenreColors = listOf(
Color(0xFFB85C38), Color(0xFF5578C8), Color(0xFF7B5AB6),
Color(0xFF2F8F83), Color(0xFFD18A32), Color(0xFFB44E76),
)
private fun genreIcon(label: String): ImageVector = when {
label.contains("comedy", true) -> Icons.Default.TheaterComedy
label.contains("music", true) -> Icons.Default.LiveTv
label.contains("children", true) || label.contains("family", true) -> Icons.Default.SentimentVerySatisfied
label.contains("sport", true) -> Icons.Default.PlayCircleFilled
label.contains("document", true) -> Icons.Default.Movie
else -> Icons.Default.AutoAwesome
}
@Composable
private fun GenreTiles(
genres: List<SearchSuggestion>,
resultsEntry: FocusRequester,
keyboardReturn: FocusRequester,
onSelected: (String) -> Unit,
) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rowItemsIndexed(genres.take(6), key = { _, item -> item.label }) { index, genre ->
val color = GenreColors[index % GenreColors.size]
FocusScaleContainer(
onFocused = {},
onClick = { onSelected(genre.label) },
contentDescription = "Search the ${genre.label} genre",
modifier = Modifier
.width(126.dp)
.height(72.dp)
.clip(RoundedCornerShape(10.dp))
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
.focusProperties { if (index == 0) left = keyboardReturn },
) { focused ->
Box(
Modifier
.fillMaxSize()
.background(if (focused) color.copy(alpha = 0.95f) else color),
) {
Icon(
genreIcon(genre.label),
contentDescription = null,
tint = Color.White.copy(alpha = 0.9f),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(10.dp)
.size(30.dp),
)
Text(
genre.label,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomStart).padding(10.dp),
)
}
}
}
}
}
@Composable
private fun SuggestionChips(
suggestions: List<SearchSuggestion>,
resultsEntry: FocusRequester?,
keyboardReturn: FocusRequester,
onSelected: (String) -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
suggestions.take(6).forEachIndexed { index, suggestion ->
FocusScaleContainer(
onFocused = {},
onClick = { onSelected(suggestion.label) },
contentDescription = when (suggestion.kind) {
SearchSuggestion.Kind.RECENT -> "Search again for ${suggestion.label}"
SearchSuggestion.Kind.GENRE -> "Search the ${suggestion.label} genre"
},
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.then(if (index == 0 && resultsEntry != null) Modifier.focusRequester(resultsEntry) else Modifier)
.focusProperties { if (index == 0) left = keyboardReturn },
) { focused ->
Text(
suggestion.label.uppercase(),
color = if (focused) KeyLabelFocused else KeyLabel,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier
.background(if (focused) KeyFocused else KeyIdle)
.padding(horizontal = 12.dp, vertical = 7.dp),
)
}
}
}
}
@Composable
private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean) {
val message = when {
showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off."
state.isLoading -> "Searching…"
else -> "Nothing in this library matches that. Try fewer letters, or a different spelling."
}
Text(message, color = Muted, fontSize = 17.sp, modifier = Modifier.padding(top = 40.dp))
}
@Composable
private fun SearchError(
message: String,
onRetry: () -> Unit,
resultsEntry: FocusRequester,
keyboardReturn: FocusRequester,
) {
Column(Modifier.padding(top = 36.dp)) {
Text(message, color = Heading, fontSize = 18.sp)
Spacer(Modifier.height(14.dp))
FocusScaleContainer(
onFocused = {},
onClick = onRetry,
contentDescription = "Try the search again",
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.focusRequester(resultsEntry)
.focusProperties { left = keyboardReturn },
) { focused ->
Text(
"Try again",
color = if (focused) KeyLabelFocused else KeyLabel,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.background(if (focused) KeyFocused else KeyIdle)
.padding(horizontal = 22.dp, vertical = 11.dp),
)
}
}
}
private val CARD_SPACING = 16.dp
private const val KEYBOARD_COLUMNS = 6
private const val ACTION_ROW_INDEX = 36
private const val BACKSPACE_CODE = 8
private const val FIRST_PRINTABLE_CODE = 32
@@ -0,0 +1,255 @@
package com.ponzischeme89.memby.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.model.BaseItem
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* Something to offer when there is nothing to search for yet. Kept deliberately cheap:
* both kinds are built from data the app already has in memory, because an empty search
* box is not worth a server request.
*/
data class SearchSuggestion(val label: String, val kind: Kind) {
enum class Kind { RECENT, GENRE }
}
data class SearchUiState(
val query: String = "",
val results: List<BaseItem> = emptyList(),
val suggestions: List<SearchSuggestion> = emptyList(),
val isLoading: Boolean = false,
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
val hasSearched: Boolean = false,
val errorMessage: String? = null,
) {
/** The query is long enough to search but nothing came back. */
val isEmptyResult: Boolean
get() = hasSearched && !isLoading && errorMessage == null && results.isEmpty()
/** Nothing typed yet: the pane shows discovery rather than results. */
val isDiscovery: Boolean
get() = !shouldSearch(query)
}
/**
* Instant search.
*
* Typing feeds [onQueryChanged]; a debounce, a `distinctUntilChanged` and a
* `collectLatest` do the rest. The last of those is what makes rapid typing safe: it
* cancels the in-flight request when a newer query arrives, so a slow response for "bre"
* can never overwrite the results for "break".
*/
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
private val _state = MutableStateFlow(SearchUiState())
val state: StateFlow<SearchUiState> = _state.asStateFlow()
private val queryFlow = MutableStateFlow("")
/**
* Results for queries typed earlier in this session. Bounded and access-ordered, so
* backspacing through a word redraws instantly instead of re-querying every prefix.
*/
private val cache = object : LinkedHashMap<String, List<BaseItem>>(16, 0.75f, true) {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<String, List<BaseItem>>?,
): Boolean = size > CACHE_ENTRIES
}
private val recentQueries = ArrayDeque<String>()
private var genreSuggestions: List<String> = emptyList()
init {
viewModelScope.launch {
queryFlow
.debounce(DEBOUNCE_MS)
.map { it.trim() }
.distinctUntilChanged()
// collectLatest rather than flatMapLatest: the search is a one-shot
// suspend call, not a flow, and this cancels the previous one on the
// same terms while keeping the repository's plain suspend signature.
.collectLatest { term -> runSearch(term) }
}
viewModelScope.launch {
repository.getRecentSearches().forEach { term ->
if (recentQueries.none { it.equals(term, ignoreCase = true) }) {
recentQueries.addLast(term)
}
}
while (recentQueries.size > MAX_RECENT_QUERIES) recentQueries.removeLast()
refreshSuggestions()
}
refreshSuggestions()
}
/** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */
fun onQueryChanged(query: String) {
// The visible field updates immediately; only the *search* is debounced.
_state.update { it.copy(query = query) }
queryFlow.value = query
}
fun appendToQuery(text: String) = onQueryChanged(state.value.query + text)
fun backspace() = onQueryChanged(state.value.query.dropLast(1))
fun clearQuery() {
// Clear immediately so results from a genre tile cannot remain visible while the
// debounced empty-query transition is pending.
_state.update { it.copy(query = "", results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null) }
queryFlow.value = ""
}
/** Retry after an error, without disturbing the query or the keyboard. */
fun retry() {
val term = state.value.query.trim()
if (!shouldSearch(term)) return
cache.remove(term)
viewModelScope.launch { runSearch(term) }
}
/**
* Genre chips for the empty state, taken from items the home screen already loaded.
* Nothing is fetched: if home has no data yet, the chips simply do not appear.
*/
fun setDiscoveryItems(items: List<BaseItem>) {
val genres = items.asSequence()
.flatMap { it.genres.asSequence() }
.map { it.trim() }
.filter { it.isNotEmpty() }
.groupingBy { it }
.eachCount()
.entries
.sortedByDescending { it.value }
.take(MAX_GENRE_SUGGESTIONS)
.map { it.key }
if (genres != genreSuggestions) {
genreSuggestions = genres
refreshSuggestions()
}
}
private suspend fun runSearch(term: String) {
if (!shouldSearch(term)) {
// Back to the discovery state, but the previous results are dropped rather
// than left behind a shorter query they no longer match.
_state.update {
it.copy(results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null)
}
return
}
cache[term]?.let { cached ->
_state.update {
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
}
return
}
// Previous results stay on screen underneath the spinner: a flash of empty pane
// between two letters reads as breakage, not as progress.
_state.update { it.copy(isLoading = true, errorMessage = null) }
runCatching { repository.search(term) }
.onSuccess { items ->
val ranked = rankSearchResults(term, items)
cache[term] = ranked
rememberQuery(term)
viewModelScope.launch { repository.recordSearch(term) }
_state.update {
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
}
}
.onFailure { error ->
// A cancelled search is the normal case while typing, not a failure.
if (error is kotlinx.coroutines.CancellationException) throw error
_state.update {
it.copy(isLoading = false, hasSearched = true, errorMessage = friendlyEmbyError(error))
}
}
}
private fun rememberQuery(term: String) {
recentQueries.removeAll { it.equals(term, ignoreCase = true) }
recentQueries.addFirst(term)
while (recentQueries.size > MAX_RECENT_QUERIES) recentQueries.removeLast()
refreshSuggestions()
}
private fun refreshSuggestions() {
val suggestions = recentQueries.map { SearchSuggestion(it, SearchSuggestion.Kind.RECENT) } +
genreSuggestions.map { SearchSuggestion(it, SearchSuggestion.Kind.GENRE) }
_state.update { it.copy(suggestions = suggestions) }
}
companion object {
const val DEBOUNCE_MS = 250L
const val MIN_QUERY_LENGTH = 2
private const val CACHE_ENTRIES = 24
private const val MAX_RECENT_QUERIES = 6
private const val MAX_GENRE_SUGGESTIONS = 6
}
}
class SearchViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchViewModel(repository) as T
}
/**
* Two characters, not one.
*
* A single letter matches a large fraction of any library, so the request is slow, the
* results are noise, and it fires on the way to every real query. Multi-word queries are
* untouched — only the trimmed length matters.
*/
fun shouldSearch(query: String): Boolean = query.trim().length >= SearchViewModel.MIN_QUERY_LENGTH
/**
* Orders results so the obvious answer is first.
*
* The backend already sorts by its own relevance (Postgres `ts_rank`, or Emby's own
* ordering), and that ordering is *preserved within each tier* — this only lifts the
* matches a person would be annoyed to find below the fold. Sorting is stable, so a
* backend that already got it right is not reshuffled.
*/
fun rankSearchResults(query: String, items: List<BaseItem>): List<BaseItem> {
val term = query.trim().lowercase()
if (term.isEmpty()) return items
return items.sortedBy { item -> matchTier(term, item) }
}
private fun matchTier(term: String, item: BaseItem): Int {
val name = item.name.trim().lowercase()
val series = item.seriesName.orEmpty().trim().lowercase()
return when {
name == term -> 0
series == term -> 1
name.startsWith(term) -> 2
series.startsWith(term) -> 3
name.containsWordStartingWith(term) -> 4
name.contains(term) -> 5
series.contains(term) -> 6
// Everything else the backend returned: genre, year or overview matches. Kept,
// because "no exact match but here is the related thing" beats an empty pane.
else -> 7
}
}
/** "star" should rank higher in "Lone Star" than in "Costar". */
private fun String.containsWordStartingWith(term: String): Boolean =
split(' ', '-', ':', '.', '\'').any { it.startsWith(term) }
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The focused pill is white, so its label has to go dark to stay readable. -->
<item android:state_focused="true" android:color="#FF0B0E11" />
<item android:color="#FFFFFFFF" />
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#FF090B0D" android:state_focused="true" />
<item android:color="#FFFFFFFF" />
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#FF090B0D" android:state_focused="true" />
<item android:color="#FFFFFFFF" />
</selector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.42,-1.41L7.83,13H20v-2z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M7.41,8.59L12,13.17l4.59,-4.58L18,10l-6,6 -6,-6z" />
</vector>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F2101418" />
<corners android:radius="14dp" />
<stroke
android:width="1dp"
android:color="#26FFFFFF" />
</shape>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Focused reads as a filled white pill, matching the transport controls: on a TV the
focused item has to be unmistakable from across the room. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="#FFFFFFFF" />
<corners android:radius="8dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#FF52B54B" />
<corners android:radius="8dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="#FFFFFFFF" />
<corners android:radius="8dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#28FFFFFF" />
<corners android:radius="8dp" />
<stroke
android:width="1dp"
android:color="#30FFFFFF" />
</shape>
</item>
</selector>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FF24292E" />
<stroke
android:width="1dp"
android:color="#24FFFFFF" />
<corners android:radius="10dp" />
</shape>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="oval">
<solid android:color="#FF52B54B" />
</shape>
</item>
<item android:state_focused="true">
<shape android:shape="oval">
<solid android:color="#FFFFFFFF" />
</shape>
</item>
<item>
<shape android:shape="oval">
<solid android:color="#28FFFFFF" />
<stroke
android:width="1dp"
android:color="#30FFFFFF" />
</shape>
</item>
</selector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="90"
android:endColor="#F2000000"
android:startColor="#00000000"
android:type="linear" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="270"
android:endColor="#00000000"
android:startColor="#D9000000"
android:type="linear" />
</shape>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="#FFFFFFFF" />
<corners android:radius="12dp" />
</shape>
</item>
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="#2E52B54B" />
<stroke
android:width="1dp"
android:color="#B852B54B" />
<corners android:radius="12dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#16FFFFFF" />
<stroke
android:width="1dp"
android:color="#20FFFFFF" />
<corners android:radius="12dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FA0B0E11" />
<stroke
android:width="1dp"
android:color="#24FFFFFF" />
<corners
android:bottomLeftRadius="24dp"
android:topLeftRadius="24dp" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="315"
android:centerColor="#FF0D1114"
android:endColor="#FF050708"
android:startColor="#FF171D21"
android:type="linear" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#38000000" />
<stroke
android:width="1dp"
android:color="#28FFFFFF" />
<corners android:radius="14dp" />
</shape>
+161
View File
@@ -0,0 +1,161 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- <merge> rather than a FrameLayout root: setContentView already gives us a full-screen
FrameLayout to merge into, so this saves a redundant level in the view hierarchy. -->
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.media3.ui.PlayerView
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000"
app:auto_show="true"
app:controller_layout_id="@layout/memby_player_controls"
app:hide_on_touch="true"
app:keep_content_on_player_reset="true"
app:resize_mode="fit"
app:show_buffering="never"
app:surface_type="surface_view"
app:use_controller="true" />
<!-- Above the video, below the loading overlay: a slide that is still starting has
nothing to say about what comes next. -->
<include layout="@layout/player_next_up_banner" />
<!-- App-owned subtitle controls. ExoPlayer supplies tracks and rendering only; this
overlay deliberately stays in Memby's TV design language. -->
<include layout="@layout/player_subtitle_overlay" />
<!-- Cast is metadata-only and never pauses or rebuilds the player. -->
<include layout="@layout/player_cast_overlay" />
<LinearLayout
android:id="@+id/playback_loading"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F2050708"
android:focusable="false"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/playback_loading_logo"
android:layout_width="82dp"
android:layout_height="70dp"
android:contentDescription="@string/playback_loading"
android:scaleType="fitCenter"
android:src="@drawable/emby_logo" />
<TextView
android:id="@+id/playback_loading_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/playback_loading"
android:textColor="#FFFFFFFF"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/playback_loading_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:text="@string/playback_loading_hint"
android:textColor="#FF929AA0"
android:textSize="14sp" />
</LinearLayout>
<!-- One cheap overlay, no second ExoPlayer. The programme prepares underneath it. -->
<include layout="@layout/player_preroll" />
<!-- Deliberately outside PlayerView's controller hierarchy: a fatal error must remain
actionable after Media3 hides its transport controls. -->
<FrameLayout
android:id="@+id/playback_error"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F2050708"
android:focusable="false"
android:visibility="gone">
<LinearLayout
android:layout_width="600dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="36dp">
<ImageView
android:layout_width="72dp"
android:layout_height="62dp"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:src="@drawable/emby_logo" />
<TextView
android:id="@+id/playback_error_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:textColor="#FFFFFFFF"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:id="@+id/playback_error_detail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center"
android:lineSpacingExtra="3dp"
android:textColor="#BFFFFFFF"
android:textSize="17sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:orientation="horizontal">
<Button
android:id="@+id/playback_error_retry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/next_up_primary_button"
android:focusable="true"
android:minWidth="0dp"
android:paddingStart="28dp"
android:paddingTop="10dp"
android:paddingEnd="28dp"
android:paddingBottom="10dp"
android:stateListAnimator="@null"
android:text="@string/playback_try_again"
android:textAllCaps="false"
android:textColor="@color/next_up_button_text"
android:textSize="16sp" />
<Button
android:id="@+id/playback_error_exit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:background="@drawable/next_up_secondary_button"
android:focusable="true"
android:minWidth="0dp"
android:paddingStart="28dp"
android:paddingTop="10dp"
android:paddingEnd="28dp"
android:paddingBottom="10dp"
android:stateListAnimator="@null"
android:text="@string/playback_back_to_memby"
android:textAllCaps="false"
android:textColor="@color/next_up_button_text"
android:textSize="16sp" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
</merge>
@@ -0,0 +1,246 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This is a PlayerView controller layout (app:controller_layout_id), so it is bound to
media3-ui's exo_* ids by contract. The transport buttons reuse media3's own icons and
descriptions for the same reason; they are marked private, hence the tools:ignore. If a
media3 upgrade ever drops one, copy it into res/ here rather than un-suppressing. -->
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<View
android:id="@id/exo_controls_background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent" />
<View
android:layout_width="match_parent"
android:layout_height="210dp"
android:layout_gravity="top"
android:background="@drawable/player_osd_top_gradient" />
<View
android:layout_width="match_parent"
android:layout_height="300dp"
android:layout_gravity="bottom"
android:background="@drawable/player_osd_bottom_gradient" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="140dp"
android:layout_gravity="top|start"
android:layout_marginStart="48dp"
android:layout_marginTop="30dp"
android:gravity="center_vertical"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.14"
android:text="@string/player_now_playing"
android:textColor="#B8FFFFFF"
android:textSize="11sp" />
<ImageView
android:id="@+id/player_title_logo"
android:layout_width="320dp"
android:layout_height="92dp"
android:layout_marginTop="7dp"
android:adjustViewBounds="true"
android:contentDescription="@string/player_title_logo"
android:scaleType="fitStart"
android:visibility="invisible" />
<TextView
android:id="@+id/player_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:maxLines="2"
android:maxWidth="600dp"
android:textColor="#FFFFFFFF"
android:textSize="28sp"
android:textStyle="bold"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/player_stream_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_marginTop="44dp"
android:layout_marginEnd="48dp"
android:background="@drawable/player_status_background"
android:letterSpacing="0.08"
android:paddingStart="13dp"
android:paddingTop="7dp"
android:paddingEnd="13dp"
android:paddingBottom="7dp"
android:textColor="#DFFFFFFF"
android:textSize="11sp"
android:visibility="gone" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="210dp"
android:layout_gravity="bottom"
android:gravity="bottom"
android:orientation="vertical"
android:paddingStart="48dp"
android:paddingEnd="48dp"
android:paddingBottom="28dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@id/exo_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFFFF"
android:textSize="15sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="6dp"
android:paddingEnd="6dp"
android:text="@string/player_position_separator"
android:textColor="#80FFFFFF"
android:textSize="14sp" />
<TextView
android:id="@id/exo_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#AFFFFFFF"
android:textSize="14sp" />
<TextView
android:id="@+id/player_remaining"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="@string/player_loading_duration"
android:textColor="#BFFFFFFF"
android:textSize="13sp" />
</LinearLayout>
<TextView
android:id="@+id/player_finish_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
</LinearLayout>
<androidx.media3.ui.DefaultTimeBar
android:id="@id/exo_progress"
android:layout_width="match_parent"
android:layout_height="34dp"
android:focusable="true"
app:ad_marker_color="#80FFFFFF"
app:bar_height="4dp"
app:buffered_color="#70FFFFFF"
app:played_ad_marker_color="#FFFFFFFF"
app:played_color="#FF52B54B"
app:scrubber_color="#FFFFFFFF"
app:scrubber_dragged_size="18dp"
app:scrubber_enabled_size="14dp"
app:touch_target_height="28dp"
app:unplayed_color="#40FFFFFF" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="72dp"
android:focusable="false">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start|center_vertical"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageButton
android:id="@+id/player_exit"
style="@style/MembyPlayerControlButton"
android:contentDescription="@string/player_back"
android:src="@drawable/ic_player_back" />
<ImageButton
android:id="@+id/player_hide_controls"
style="@style/MembyPlayerControlButton"
android:contentDescription="@string/player_hide_controls"
android:src="@drawable/ic_player_hide" />
</LinearLayout>
<LinearLayout
android:id="@id/exo_center_controls"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@id/exo_rew"
style="@style/MembyPlayerControlButton"
android:contentDescription="@string/exo_controls_rewind_description"
android:src="@drawable/exo_icon_rewind"
tools:ignore="PrivateResource" />
<ImageButton
android:id="@id/exo_play_pause"
style="@style/MembyPlayerPrimaryControlButton"
android:contentDescription="@string/exo_controls_play_description"
android:focusedByDefault="true"
android:src="@drawable/exo_icon_play"
tools:ignore="PrivateResource,UnusedAttribute" />
<ImageButton
android:id="@id/exo_ffwd"
style="@style/MembyPlayerControlButton"
android:contentDescription="@string/exo_controls_fastforward_description"
android:src="@drawable/exo_icon_fastforward"
tools:ignore="PrivateResource" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end|center_vertical"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageButton
android:id="@id/exo_subtitle"
style="@style/MembyPlayerControlButton"
android:contentDescription="@string/exo_controls_cc_enabled_description"
android:src="@drawable/exo_ic_subtitle_on"
tools:ignore="PrivateResource" />
<ImageButton
android:id="@id/exo_settings"
style="@style/MembyPlayerControlButton"
android:contentDescription="@string/exo_controls_settings_description"
android:src="@drawable/exo_ic_settings"
tools:ignore="PrivateResource" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
</merge>
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_cast_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#70000000"
android:clickable="true"
android:focusable="true"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="340dp"
android:layout_gravity="bottom"
android:background="@drawable/player_overlay_panel_background"
android:orientation="vertical"
android:paddingStart="48dp"
android:paddingTop="25dp"
android:paddingEnd="48dp"
android:paddingBottom="22dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.14"
android:text="@string/player_now_playing"
android:textColor="#FF52B54B"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/player_cast"
android:textColor="#FFFFFFFF"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_cast_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/player_cast_loading"
android:textColor="#AFFFFFFF"
android:textSize="14sp" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="14dp"
android:layout_weight="1"
android:clipToPadding="false"
android:fillViewport="false"
android:overScrollMode="never">
<LinearLayout
android:id="@+id/player_cast_people"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="top"
android:orientation="horizontal" />
</HorizontalScrollView>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/player_back_to_close"
android:textColor="#78FFFFFF"
android:textSize="12sp" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A shallow native end-of-episode overlay. The outgoing PlayerView is scaled into the
open left side while this single prefetched episode card fades in. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/player_next_up"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="false"
android:visibility="gone">
<LinearLayout
android:layout_width="420dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:layout_marginEnd="50dp"
android:background="@drawable/next_up_banner_background"
android:orientation="vertical"
android:padding="20dp">
<ImageView
android:id="@+id/player_next_up_image"
android:layout_width="match_parent"
android:layout_height="214dp"
android:background="#FF1B2026"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:letterSpacing="0.14"
android:text="@string/next_up_label"
android:textColor="#FF69CD61"
android:textSize="11sp" />
<TextView
android:id="@+id/player_next_up_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="#FFFFFFFF"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_next_up_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#BFFFFFFF"
android:textSize="13sp" />
<TextView
android:id="@+id/player_next_up_countdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="#FF69CD61"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal"
tools:ignore="ButtonStyle">
<Button
android:id="@+id/player_next_up_play"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/next_up_primary_button"
android:focusable="true"
android:minWidth="0dp"
android:paddingStart="22dp"
android:paddingTop="9dp"
android:paddingEnd="22dp"
android:paddingBottom="9dp"
android:stateListAnimator="@null"
android:text="@string/next_up_play_now"
android:textAllCaps="false"
android:textColor="@color/next_up_button_text"
android:textSize="15sp" />
<Button
android:id="@+id/player_next_up_dismiss"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:background="@drawable/next_up_secondary_button"
android:focusable="true"
android:minWidth="0dp"
android:paddingStart="22dp"
android:paddingTop="9dp"
android:paddingEnd="22dp"
android:paddingBottom="9dp"
android:stateListAnimator="@null"
android:text="@string/next_up_dismiss"
android:textAllCaps="false"
android:textColor="@color/next_up_button_text"
android:textSize="15sp" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
+118
View File
@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_preroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FF050708"
android:clickable="true"
android:focusable="false"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="64dp"
android:paddingTop="54dp"
android:paddingEnd="64dp"
android:paddingBottom="54dp">
<!-- Reserved for the eventual pre-roll media asset. Keeping this a plain View is
virtually free and lets the real programme buffer beneath the overlay. -->
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="52dp"
android:layout_weight="1.62"
android:background="@drawable/player_preroll_video_background">
<ImageView
android:layout_width="74dp"
android:layout_height="64dp"
android:layout_gravity="start|bottom"
android:layout_marginStart="28dp"
android:layout_marginBottom="25dp"
android:alpha="0.24"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:src="@drawable/emby_logo" />
<TextView
android:id="@+id/player_preroll_countdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android:layout_marginEnd="28dp"
android:layout_marginBottom="28dp"
android:background="@drawable/player_status_background"
android:paddingStart="18dp"
android:paddingTop="10dp"
android:paddingEnd="18dp"
android:paddingBottom="10dp"
android:text="@string/player_preroll_countdown_initial"
android:textColor="#FFFFFFFF"
android:textSize="15sp"
android:textStyle="bold" />
</FrameLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Coming up"
android:textColor="#FFFFFFFF"
android:textSize="30sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:text="From your Sonarr calendar"
android:textColor="#FF8E979D"
android:textSize="13sp" />
<TextView
android:id="@+id/player_preroll_today_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="25dp"
android:text="AIRING TODAY"
android:textAllCaps="true"
android:textColor="#FF55B94D"
android:textSize="12sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/player_preroll_today"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
<TextView
android:id="@+id/player_preroll_week_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="22dp"
android:text="THIS WEEK"
android:textAllCaps="true"
android:textColor="#FF8E979D"
android:textSize="12sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/player_preroll_week"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_subtitle_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#70000000"
android:clickable="true"
android:focusable="true"
android:visibility="gone">
<LinearLayout
android:layout_width="520dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:layout_marginStart="30dp"
android:background="@drawable/player_overlay_panel_background"
android:orientation="vertical"
android:paddingStart="34dp"
android:paddingTop="32dp"
android:paddingEnd="34dp"
android:paddingBottom="26dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.14"
android:text="@string/player_playback_options"
android:textColor="#FF52B54B"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:text="@string/player_subtitles"
android:textColor="#FFFFFFFF"
android:textSize="30sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:lineSpacingExtra="2dp"
android:text="@string/player_subtitle_overlay_hint"
android:textColor="#AFFFFFFF"
android:textSize="14sp" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="24dp"
android:layout_weight="1"
android:clipToPadding="false"
android:fillViewport="true"
android:overScrollMode="never">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.12"
android:text="@string/player_subtitle_track"
android:textColor="#8FFFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/player_subtitle_tracks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="9dp"
android:orientation="vertical" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="25dp"
android:letterSpacing="0.12"
android:text="@string/player_text_size"
android:textColor="#8FFFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/player_subtitle_sizes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="9dp"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/player_back_to_close"
android:textColor="#78FFFFFF"
android:textSize="12sp" />
</LinearLayout>
</FrameLayout>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="MembyPlayerControlButton">
<item name="android:layout_width">48dp</item>
<item name="android:layout_height">48dp</item>
<item name="android:layout_marginEnd">10dp</item>
<item name="android:background">@drawable/player_control_button_background</item>
<item name="android:focusable">true</item>
<item name="android:padding">13dp</item>
<item name="android:tint">@color/player_control_tint</item>
</style>
<style name="MembyPlayerPrimaryControlButton" parent="MembyPlayerControlButton">
<item name="android:layout_width">60dp</item>
<item name="android:layout_height">60dp</item>
<item name="android:layout_marginEnd">14dp</item>
<item name="android:padding">15dp</item>
</style>
</resources>
@@ -0,0 +1,48 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Test
class CastMetadataTest {
@Test
fun `cast contains actors and preserves Emby order`() {
val item = BaseItem(
id = "movie",
people = listOf(
EmbyPerson(id = "director", name = "Director", type = "Director"),
EmbyPerson(id = "lead", name = "Lead", role = "Detective", type = "Actor"),
EmbyPerson(id = "support", name = "Support", role = "Doctor", type = "Actor"),
),
)
assertEquals(listOf("lead", "support"), item.cast.map(EmbyPerson::id))
assertEquals("Detective", item.cast.first().role)
}
@Test
fun `decodes emby people and portrait tags`() {
val item = Json { ignoreUnknownKeys = true }.decodeFromString<BaseItem>(
"""
{
"Id":"movie",
"Name":"Example",
"Type":"Movie",
"People":[{
"Id":"person-1",
"Name":"Alex Actor",
"Role":"Morgan",
"Type":"Actor",
"PrimaryImageTag":"portrait-tag"
}]
}
""".trimIndent(),
)
assertEquals("person-1", item.cast.single().id)
assertEquals("portrait-tag", item.cast.single().primaryImageTag)
}
}
@@ -0,0 +1,89 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.GatewayAlert
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ServiceAlertTest {
private val json = Json { ignoreUnknownKeys = true }
private fun alert(id: String, title: String = "Northbound", message: String = "S02E04 aired") =
GatewayAlert(id = id, kind = "sonarr-aired", title = title, message = message)
@Test
fun `picks the first alert this tv has not seen`() {
val chosen = firstUnseenAlert(
listOf(alert("a"), alert("b"), alert("c")),
seen = setOf("a", "b"),
)
assertEquals("c", chosen?.id)
}
@Test
fun `an alert already shown never comes back`() {
assertNull(firstUnseenAlert(listOf(alert("a")), seen = setOf("a")))
}
@Test
fun `incomplete alerts are dropped rather than drawn empty`() {
val alerts = listOf(
alert(id = ""),
alert(id = "b", title = " "),
alert(id = "c", message = ""),
alert(id = "d"),
)
assertEquals("d", firstUnseenAlert(alerts, seen = emptySet())?.id)
}
@Test
fun `an alert still on offer keeps waiting for a screen`() {
assertFalse(
pendingAlertExpired(
pendingId = "a",
shownId = null,
alerts = listOf(alert("a"), alert("b")),
),
)
}
@Test
fun `an unshown alert is given up on once the gateway stops offering it`() {
assertTrue(pendingAlertExpired(pendingId = "a", shownId = null, alerts = listOf(alert("b"))))
assertTrue(pendingAlertExpired(pendingId = "a", shownId = null, alerts = emptyList()))
}
@Test
fun `an alert already on screen is left to its own timer`() {
assertFalse(pendingAlertExpired(pendingId = "a", shownId = "a", alerts = emptyList()))
}
@Test
fun `status decodes without alerts for a gateway that predates them`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"maintenance":false,"message":""}""",
)
assertTrue(status.alerts.isEmpty())
}
@Test
fun `status decodes the alert payload the gateway sends`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""
{"maintenance":false,"message":"","alerts":[{
"id":"sonarr:7:42:aired","kind":"sonarr-aired","title":"Northbound",
"message":"S02E04 aired at 9:00 PM and will be in Emby soon.",
"itemId":"sonarr:7:42","imageTag":"sonarr","airedAt":"2026-07-27T21:00:00+12:00"
}]}
""".trimIndent(),
)
val alert = status.alerts.single()
assertEquals("sonarr:7:42:aired", alert.id)
assertEquals("sonarr:7:42", alert.itemId)
assertEquals("sonarr", alert.imageTag)
}
}
@@ -0,0 +1,83 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.MediaStream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SubtitleSupportTest {
@Test
fun `normalizes relative delivery urls and preserves selection metadata`() {
val tracks = subtitleTracks(
streams = listOf(
MediaStream(
index = 3,
type = "Subtitle",
codec = "srt",
displayTitle = "English SDH",
language = "eng",
isDefault = true,
isForced = true,
isTextSubtitleStream = true,
deliveryUrl = "/Videos/a/Subtitles/3/Stream.srt?x=1",
),
),
serverUrl = "https://emby.example",
token = "a b",
itemId = "item",
mediaSourceId = "source",
)
assertEquals(1, tracks.size)
assertEquals(
"https://emby.example/Videos/a/Subtitles/3/Stream.srt?x=1&api_key=a+b",
tracks.single().url,
)
assertEquals("application/x-subrip", tracks.single().mimeType)
assertEquals("eng", tracks.single().language)
assertTrue(tracks.single().isDefault)
assertTrue(tracks.single().isForced)
assertTrue(tracks.single().isHearingImpaired)
}
@Test
fun `keeps supported text subtitles and rejects bitmap formats`() {
assertEquals("text/vtt", subtitleMimeType("webvtt"))
assertEquals("text/x-ssa", subtitleMimeType(null, "https://host/subtitle.ass?token=x"))
assertEquals(null, subtitleMimeType("pgssub"))
assertEquals(null, subtitleMimeType("dvdsub"))
}
@Test
fun `does not duplicate authentication already present`() {
assertEquals(
"https://host/sub.srt?api_key=existing",
authenticatedDeliveryUrl("https://other", "https://host/sub.srt?api_key=existing", "new"),
)
}
@Test
fun `image subtitles remain visible as encode choices without an overlay url`() {
val track = subtitleTracks(
streams = listOf(
MediaStream(
index = 7,
type = "Subtitle",
codec = "pgssub",
language = "eng",
isForced = true,
deliveryMethod = "Encode",
),
),
serverUrl = "https://emby.example",
token = "token",
itemId = "item",
mediaSourceId = "source",
).single()
assertEquals("Encode", track.deliveryMethod)
assertEquals("", track.url)
assertEquals("", track.mimeType)
assertTrue(track.isForced)
}
}
@@ -0,0 +1,101 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.HomeSnapshot
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AiringTodayTagsTest {
@Test
fun `tags matching recommended series from today's Sonarr schedule`() {
val home = HomeSnapshot(
rows = listOf(
HomeRow(
id = "sonarr-airing-today",
title = "Shows airing today",
kind = "schedule",
items = listOf(
BaseItem(
id = "sonarr:7:42",
name = "The Bear",
type = "MembySonarrEpisode",
membySource = "sonarr",
),
),
),
HomeRow(
id = "recommended",
title = "Recommended",
kind = "recommended",
items = listOf(
BaseItem(id = "series-1", name = "The Bear", type = "Series"),
BaseItem(id = "series-2", name = "Severance", type = "Series"),
),
),
),
)
val recommendations = home.withAiringTodayTags().rows.last().items
assertTrue(recommendations[0].membyAiringToday)
assertFalse(recommendations[1].membyAiringToday)
}
@Test
fun `show matching ignores punctuation spacing and case`() {
val home = HomeSnapshot(
rows = listOf(
HomeRow(
id = "sonarr-airing-today",
title = "Shows airing today",
items = listOf(
BaseItem(
id = "sonarr:1:2",
name = "Marvel's DAREDEVIL",
type = "MembySonarrEpisode",
membySource = "sonarr",
),
),
),
HomeRow(
id = "recommended",
title = "Recommended",
items = listOf(
BaseItem(id = "series-1", name = "Marvels Daredevil", type = "Series"),
),
),
),
)
assertTrue(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
}
@Test
fun `does not tag movies with a matching title`() {
val home = HomeSnapshot(
rows = listOf(
HomeRow(
id = "sonarr-airing-today",
title = "Shows airing today",
items = listOf(
BaseItem(
id = "sonarr:1:2",
name = "Fargo",
type = "MembySonarrEpisode",
membySource = "sonarr",
),
),
),
HomeRow(
id = "recommended",
title = "Recommended",
items = listOf(BaseItem(id = "movie-1", name = "Fargo", type = "Movie")),
),
),
)
assertFalse(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
}
}
@@ -0,0 +1,46 @@
package com.ponzischeme89.memby.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class ProfileViewModelStoreOwnerTest {
private val factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ProbeViewModel() as T
}
@Test
fun `profiles have isolated view models and clearing cancels the old one`() {
val mattOwner = ProfileViewModelStoreOwner()
val familyOwner = ProfileViewModelStoreOwner()
val matt = ViewModelProvider(mattOwner, factory)[ProbeViewModel::class.java]
val mattAgain = ViewModelProvider(mattOwner, factory)[ProbeViewModel::class.java]
val family = ViewModelProvider(familyOwner, factory)[ProbeViewModel::class.java]
assertSame(matt, mattAgain)
assertNotSame(matt, family)
assertFalse(matt.cleared)
assertFalse(family.cleared)
mattOwner.clear()
assertTrue(matt.cleared)
assertFalse(family.cleared)
}
private class ProbeViewModel : ViewModel() {
var cleared = false
private set
override fun onCleared() {
cleared = true
}
}
}
@@ -0,0 +1,63 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Test
class SeriesDetailsTest {
private fun episode(
id: String,
season: Int,
number: Int,
played: Boolean = false,
) = BaseItem(
id = id,
name = "Episode $number",
type = "Episode",
parentIndexNumber = season,
indexNumber = number,
userData = UserItemData(played = played),
)
@Test
fun `seasons are distinct and ordered with specials first`() {
val episodes = listOf(
episode("s2e1", 2, 1),
episode("s0e1", 0, 1),
episode("s1e1", 1, 1),
episode("s2e2", 2, 2),
)
assertEquals(listOf(0, 1, 2), availableSeasons(episodes))
}
@Test
fun `season list is ordered by episode number`() {
val episodes = listOf(
episode("e3", 1, 3),
episode("e1", 1, 1),
episode("e2", 1, 2),
)
assertEquals(listOf("e1", "e2", "e3"), episodesForSeason(episodes, 1).map(BaseItem::id))
}
@Test
fun `default season contains the first unwatched episode`() {
val episodes = listOf(
episode("s1e1", 1, 1, played = true),
episode("s1e2", 1, 2, played = true),
episode("s2e1", 2, 1),
)
assertEquals(2, defaultSeason(episodes))
}
@Test
fun `detail tabs default safely to episodes`() {
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("episodes"))
assertEquals(SeriesDetailSection.CAST, seriesDetailSection("cast"))
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("future-section"))
}
}
@@ -0,0 +1,132 @@
package com.ponzischeme89.memby.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.ServiceAlert
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Renders the aired banner to PNGs under `build/screenshots/`, so its layout can be
* looked at without deploying to a TV.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*ServiceAlertBannerScreenshotTest"
* ```
*
* This is the **only** part of `app/src/test` that touches Android — rendering a
* composable genuinely cannot be done in plain JUnit, so Robolectric is confined to files
* named `*ScreenshotTest.kt`. Logic tests stay pure; keep them that way.
*
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn on the launcher's
* own background, flush to the top edge exactly as `MainActivity` places it.
*
* [AlertBanner] is rendered directly rather than [ServiceAlertBanner]: the wrapper's whole
* job is the drop-in from above, and a still frame of an animation says nothing. Posters
* are null because there is no network here — which makes these the check on the fallback
* tile too.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ServiceAlertBannerScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `awaiting download`() {
capture(
"alert-banner-awaiting",
ServiceAlert(
id = "sonarr:7:42:aired",
title = "Northbound",
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
posterUrl = null,
),
)
}
@Test
fun `downloading now`() {
capture(
"alert-banner-downloading",
ServiceAlert(
id = "sonarr:8:43:aired",
title = "The Long Dark",
message = "S01E09 — Winterlight aired at 8:30 PM and is downloading now.",
posterUrl = null,
),
)
}
/** Both lines ellipsise at one line; this is the case that proves it. */
@Test
fun `long title and message`() {
capture(
"alert-banner-long-text",
ServiceAlert(
id = "sonarr:9:88:aired",
title = "A Very Long Programme Title That Will Not Fit In One Line",
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
"And Then Some More Happens After That aired at 10:30 PM and is " +
"downloading now.",
posterUrl = null,
),
)
}
/** Shortest plausible content: the bar keeps its height and the text stays put. */
@Test
fun `short title and message`() {
capture(
"alert-banner-short-text",
ServiceAlert(
id = "sonarr:3:12:aired",
title = "Dune",
message = "S01E01 aired and will be in Emby soon.",
posterUrl = null,
),
)
}
/**
* The countdown ring is full at t=0, which tells you nothing about whether it
* empties. Holding the clock and stepping it forward captures it mid-sweep.
*/
@Test
fun `countdown part way through`() {
compose.mainClock.autoAdvance = false
compose.setContent {
AlertBannerOnHomeBackground(
ServiceAlert(
id = "sonarr:7:42:aired",
title = "Northbound",
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
posterUrl = null,
),
)
}
compose.mainClock.advanceTimeBy(6_500L)
compose.onRoot().captureRoboImage("build/screenshots/alert-banner-countdown.png")
}
private fun capture(name: String, alert: ServiceAlert) {
compose.setContent { AlertBannerOnHomeBackground(alert) }
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
}
@Composable
private fun AlertBannerOnHomeBackground(alert: ServiceAlert) {
PreviewSurface(alignment = Alignment.TopCenter) {
AlertBanner(alert)
}
}
}
@@ -0,0 +1,30 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PictureSizingTest {
@Test
fun `auto fills classic four by three video`() {
assertTrue(shouldZoomVideo("auto", 640, 480, 1f))
}
@Test
fun `auto accounts for anamorphic pixel aspect ratio`() {
assertTrue(shouldZoomVideo("auto", 720, 480, 8f / 9f))
}
@Test
fun `auto preserves modern widescreen video`() {
assertFalse(shouldZoomVideo("auto", 1920, 1080, 1f))
assertFalse(shouldZoomVideo("auto", 1920, 800, 1f))
}
@Test
fun `manual modes are deterministic`() {
assertFalse(shouldZoomVideo("original", 640, 480, 1f))
assertTrue(shouldZoomVideo("fill", 1920, 1080, 1f))
}
}
@@ -0,0 +1,37 @@
package com.ponzischeme89.memby.ui.player
import androidx.media3.common.PlaybackException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PlaybackRecoveryTest {
@Test
fun networkFailuresAreSafeToRetry() {
val failure = describePlaybackFailure(
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
)
assertEquals("Connection interrupted", failure.title)
assertTrue(failure.canAutoRetry)
}
@Test
fun decoderFailuresRequireViewerAction() {
val failure = describePlaybackFailure(
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
)
assertEquals("Video format not supported", failure.title)
assertFalse(failure.canAutoRetry)
}
@Test
fun automaticRetriesAreBoundedAndBackOff() {
assertEquals(1_000L, automaticRetryDelayMs(1))
assertEquals(3_000L, automaticRetryDelayMs(2))
assertNull(automaticRetryDelayMs(3))
}
}
@@ -0,0 +1,14 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertEquals
import org.junit.Test
class PlayerTimingTest {
@Test
fun formatsRemainingTimeForViewerFriendlyDisplay() {
assertEquals("Less than a minute remaining", PlayerActivity.formatRemaining(30_000L))
assertEquals("42 min remaining", PlayerActivity.formatRemaining(42L * 60_000L))
assertEquals("1 hr remaining", PlayerActivity.formatRemaining(60L * 60_000L))
assertEquals("2 hr 5 min remaining", PlayerActivity.formatRemaining(125L * 60_000L))
}
}
@@ -0,0 +1,38 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.test.core.app.ApplicationProvider
import com.ponzischeme89.memby.R
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PrerollLayoutTest {
@Test
fun `player preroll and its background inflate`() {
val context = ApplicationProvider.getApplicationContext<Context>()
assertNotNull(context.getDrawable(R.drawable.player_preroll_video_background))
assertNotNull(
LayoutInflater.from(context).inflate(
R.layout.player_preroll,
FrameLayout(context),
false,
),
)
assertNotNull(
LayoutInflater.from(context).inflate(
R.layout.player_cast_overlay,
FrameLayout(context),
false,
),
)
}
}
@@ -0,0 +1,19 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PrerollSequenceTest {
@Test
fun `handoff waits only for the five second gate`() {
assertFalse(prerollCanHandOff(true, false, true))
assertTrue(prerollCanHandOff(true, true, true))
}
@Test
fun `handoff never starts playback in background`() {
assertFalse(prerollCanHandOff(true, true, false))
assertFalse(prerollCanHandOff(false, true, true))
}
}
@@ -0,0 +1,75 @@
package com.ponzischeme89.memby.ui.search
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SearchRankingTest {
private fun item(name: String, series: String? = null, id: String = name) =
BaseItem(id = id, name = name, seriesName = series)
private fun names(items: List<BaseItem>) = items.map { it.name }
@Test
fun `an exact title beats a title that merely starts with the query`() {
val ranked = rankSearchResults(
"dune",
listOf(item("Dune: Part Two"), item("Dunes of Mars"), item("Dune")),
)
assertEquals("Dune", ranked.first().name)
}
@Test
fun `titles starting with the query come before mid-word matches`() {
val ranked = rankSearchResults(
"star",
listOf(item("Costar"), item("Lone Star"), item("Stargate")),
)
// Prefix first, then the word-boundary match, then the buried one.
assertEquals(listOf("Stargate", "Lone Star", "Costar"), names(ranked))
}
@Test
fun `an episode is found by its series name when its own title does not match`() {
val ranked = rankSearchResults(
"severance",
listOf(item("Defiant Jazz", series = "Severance"), item("Severed Ties")),
)
assertEquals("Defiant Jazz", ranked.first().name)
}
@Test
fun `weaker matches are kept rather than dropped`() {
// A genre or overview match the backend returned: last, but still there, because
// "no exact match but here is something related" beats an empty pane.
val ranked = rankSearchResults("comedy", listOf(item("Some Unrelated Title")))
assertEquals(1, ranked.size)
}
@Test
fun `ranking is stable so the backend's own relevance order survives within a tier`() {
val backendOrder = listOf(item("Alien 3"), item("Alien"), item("Aliens"))
val ranked = rankSearchResults("alien", backendOrder)
// "Alien" is the exact match and is lifted; the other two keep the order the
// server chose rather than being re-sorted alphabetically.
assertEquals(listOf("Alien", "Alien 3", "Aliens"), names(ranked))
}
@Test
fun `an empty query leaves the list untouched`() {
val items = listOf(item("B"), item("A"))
assertEquals(items, rankSearchResults(" ", items))
}
@Test
fun `searching starts at two characters`() {
assertFalse(shouldSearch(""))
assertFalse(shouldSearch("a"))
assertFalse(shouldSearch(" a "))
assertTrue(shouldSearch("ab"))
assertTrue(shouldSearch(" the wire "))
}
}
@@ -0,0 +1,68 @@
package com.ponzischeme89.memby.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ui.PreviewSurface
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Captures the real stateless Settings panel at TV size. The fixture deliberately mixes
* enabled and disabled values so the screenshot proves state is legible without focus;
* the first row also owns focus to prove the white focus ring and green active state are
* visually distinct.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class SettingsSheetScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `active inactive and focused controls`() {
compose.setContent { SettingsPreviewFixture(overlay = false) }
compose.onRoot().captureRoboImage("build/screenshots/settings-panel.png")
}
@Test
fun `home overlay width`() {
compose.setContent { SettingsPreviewFixture(overlay = true) }
compose.onRoot().captureRoboImage("build/screenshots/settings-overlay.png")
}
@Composable
private fun SettingsPreviewFixture(overlay: Boolean) {
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { firstFocus.requestFocus() }
PreviewSurface(alignment = if (overlay) Alignment.CenterEnd else Alignment.Center) {
SettingsPanelContent(
state = SettingsPanelState(
showLogo = true,
autoPlayNext = false,
ringColor = "52B54B",
homeSections = setOf("continue", "latest"),
cardDensity = "standard",
showCardMetadata = false,
editableServer = true,
baseUrl = "https://mserver.example/releases/latest.json",
installedVersion = "0.1.60",
),
actions = SettingsPanelActions(),
overlay = overlay,
firstFocusRequester = firstFocus,
)
}
}
}
+582
View File
@@ -0,0 +1,582 @@
<#
.SYNOPSIS
Deploys the complete Memby Docker Compose stack to MATT-NAS.
.DESCRIPTION
Packages the local server build context, Docker Compose file and .env.example,
then streams them to the NAS over one SSH connection.
The remote deployment:
- checks Docker and Docker Compose;
- installs .env from the local .env.example, overwriting the deployed
copy, and keeps the old one alongside as .env.previous;
- preserves the named database volume;
- pulls Redis and PostgreSQL, then builds the Memby server;
- starts each service and waits for its health check;
- restores the previous application files if activation fails.
Configuration lives in the local .env.example and is included directly in every
deployment. It does not need to be committed or pushed before deployment.
SSH performs the password prompt directly. The password is never read or stored
by this script.
This deploys the current local working tree, including uncommitted server changes.
.EXAMPLE
.\deploy-server.ps1
.EXAMPLE
.\deploy-server.ps1 -Destination /share/Docker/Memby-test -HealthTimeoutSeconds 180
#>
#Requires -Version 7.2
[CmdletBinding()]
param(
[Parameter()]
[ValidateNotNullOrEmpty()]
[string] $SourceDirectory = $PSScriptRoot,
[Parameter()]
[ValidateNotNullOrEmpty()]
[string] $RemoteHost = '10.0.0.213',
[Parameter()]
[ValidateNotNullOrEmpty()]
[string] $RemoteUser = 'ssh',
[Parameter()]
[ValidateNotNullOrEmpty()]
[string] $Destination = '/share/Docker/Memby',
[Parameter()]
[ValidateRange(30, 600)]
[int] $HealthTimeoutSeconds = 120
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$script:CurrentStep = 0
$script:TotalSteps = 5
function Write-Banner {
Write-Host ''
Write-Host '╭─ Memby deployment' -ForegroundColor Magenta
Write-Host "│ Source $SourceDirectory (local working tree)" -ForegroundColor DarkGray
Write-Host "│ Target ${RemoteUser}@${RemoteHost}:$Destination" -ForegroundColor DarkGray
Write-Host '╰─' -ForegroundColor Magenta
Write-Host ''
}
function Write-Step {
param(
[Parameter(Mandatory)]
[string] $Message
)
$script:CurrentStep++
Write-Host ("● [{0}/{1}] {2}" -f $script:CurrentStep, $script:TotalSteps, $Message) -ForegroundColor Cyan
}
function Write-Detail {
param(
[Parameter(Mandatory)]
[string] $Message
)
Write-Host "$Message" -ForegroundColor DarkGray
}
function Write-Success {
param(
[Parameter(Mandatory)]
[string] $Message
)
Write-Host "$Message" -ForegroundColor Green
}
function Get-RequiredCommand {
param(
[Parameter(Mandatory)]
[string] $Name
)
$command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $command) {
throw "Required command '$Name' was not found in PATH."
}
return $command.Source
}
function Assert-SafeRemoteSettings {
if ($RemoteHost -notmatch '^[A-Za-z0-9._:-]+$') {
throw "RemoteHost contains unsupported characters: '$RemoteHost'."
}
if ($RemoteUser -notmatch '^[A-Za-z0-9._-]+$') {
throw "RemoteUser contains unsupported characters: '$RemoteUser'."
}
if ($Destination -notmatch '^/[A-Za-z0-9._/-]+$') {
throw "Destination must be an absolute Linux path using only letters, numbers, '.', '_', '-', and '/'."
}
$segments = $Destination.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)
if ($segments -contains '..') {
throw "Destination cannot contain '..' path segments."
}
$broadPaths = @(
'/', '/bin', '/boot', '/dev', '/etc', '/home', '/lib', '/lib64',
'/opt', '/proc', '/root', '/run', '/sbin', '/srv', '/sys', '/tmp',
'/usr', '/var'
)
if ($Destination -in $broadPaths) {
throw "Destination '$Destination' is too broad to replace safely."
}
}
function New-DeploymentArchive {
param(
[Parameter(Mandatory)]
[string] $RepositoryDirectory,
[Parameter(Mandatory)]
[string] $ArchivePath
)
& $script:TarCommand -cf $ArchivePath -C $RepositoryDirectory server docker-compose.yml .env.example
if ($LASTEXITCODE -ne 0) {
throw "Unable to create the deployment archive (tar exit code $LASTEXITCODE)."
}
if (-not (Test-Path -LiteralPath $ArchivePath -PathType Leaf)) {
throw 'The deployment archive was not created.'
}
}
function Send-ArchiveOverSsh {
param(
[Parameter(Mandatory)]
[string] $ArchivePath,
[Parameter(Mandatory)]
[string] $RemoteCommand
)
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $script:SshCommand
$startInfo.UseShellExecute = $false
$startInfo.RedirectStandardInput = $true
# Force interactive password authentication as requested. OpenSSH reads the
# password from the console while stdin carries the tar archive.
foreach ($argument in @(
'-o', 'PubkeyAuthentication=no',
'-o', 'PreferredAuthentications=keyboard-interactive,password',
'-o', 'NumberOfPasswordPrompts=3',
"$RemoteUser@$RemoteHost",
$RemoteCommand
)) {
[void] $startInfo.ArgumentList.Add($argument)
}
$process = [System.Diagnostics.Process]::new()
$process.StartInfo = $startInfo
$archiveStream = $null
$processStarted = $false
$exitCode = $null
try {
if (-not $process.Start()) {
throw 'SSH could not be started.'
}
$processStarted = $true
$archiveStream = [System.IO.File]::OpenRead($ArchivePath)
try {
$archiveStream.CopyTo($process.StandardInput.BaseStream)
}
catch [System.IO.IOException] {
# The remote side exited before reading the whole archive — a failed early
# step, or a shell that rejected the command. Its own message is already on
# screen, so let the exit code below do the explaining rather than burying it
# under a broken-pipe stack trace.
}
try { $process.StandardInput.Close() } catch [System.IO.IOException] { }
$process.WaitForExit()
$exitCode = $process.ExitCode
}
catch {
if ($processStarted -and -not $process.HasExited) {
$process.StandardInput.Close()
$process.WaitForExit()
}
throw
}
finally {
if ($archiveStream) {
$archiveStream.Dispose()
}
$process.Dispose()
}
if ($exitCode -ne 0) {
throw "Remote deployment failed with SSH exit code $exitCode. Review the last remote step above."
}
}
Write-Banner
$deploymentTimer = [System.Diagnostics.Stopwatch]::StartNew()
Write-Step 'Checking local prerequisites and settings'
Assert-SafeRemoteSettings
$script:TarCommand = Get-RequiredCommand -Name 'tar'
$script:SshCommand = Get-RequiredCommand -Name 'ssh'
Write-Detail "SSH $script:SshCommand"
Write-Success 'Local prerequisites are ready'
Write-Host ''
$workDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("memby-deploy-" + [guid]::NewGuid().ToString('N'))
$archivePath = Join-Path $workDirectory 'memby-deployment.tar'
try {
[void] (New-Item -ItemType Directory -Path $workDirectory)
Write-Step 'Selecting the local deployment source'
$checkoutDirectory = (Resolve-Path -LiteralPath $SourceDirectory -ErrorAction Stop).Path
Write-Success "Using $checkoutDirectory"
Write-Host ''
Write-Step 'Validating the Compose deployment payload'
$requiredPaths = @(
(Join-Path $checkoutDirectory 'server'),
(Join-Path $checkoutDirectory 'server/Dockerfile'),
(Join-Path $checkoutDirectory 'server/go.mod'),
(Join-Path $checkoutDirectory 'docker-compose.yml'),
(Join-Path $checkoutDirectory '.env.example')
)
foreach ($requiredPath in $requiredPaths) {
if (-not (Test-Path -LiteralPath $requiredPath)) {
throw "Required deployment file is missing: $requiredPath"
}
}
Write-Detail 'server/ build context'
Write-Detail 'docker-compose.yml'
Write-Detail '.env.example'
Write-Success 'Deployment payload is complete'
Write-Host ''
Write-Step 'Packaging the release'
New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath
$archiveSize = (Get-Item -LiteralPath $archivePath).Length
Write-Detail ("Archive size {0:N1} MiB" -f ($archiveSize / 1MB))
Write-Success 'Release archive is ready'
Write-Host ''
# This template is single-quoted so PowerShell does not expand the remote
# shell's variables. Replacement values are validated before insertion.
$remoteCommand = @'
set -eu
destination='__DESTINATION__'
health_timeout=__HEALTH_TIMEOUT__
parent=$(dirname "$destination")
staging="${destination}.new.$$"
backup="${destination}.previous.$$"
activated=0
previous_stopped=0
step() {
printf '\033[36m ● %s\033[0m\n' "$1"
}
detail() {
printf '\033[90m ↳ %s\033[0m\n' "$1"
}
success() {
printf '\033[32m ✓ %s\033[0m\n' "$1"
}
failure() {
printf '\033[31m ✗ %s\033[0m\n' "$1" >&2
}
rollback() {
status=$?
trap - EXIT
if [ "$status" -eq 0 ]; then
return
fi
failure "Deployment failed; cleaning up"
rm -rf -- "$staging"
if [ "$activated" -eq 1 ]; then
if [ -f "$destination/docker-compose.yml" ]; then
detail "Stopping the incomplete application release"
(
cd "$destination"
docker compose down --remove-orphans >/dev/null 2>&1
) || true
fi
rm -rf -- "$destination"
if [ -e "$backup" ] || [ -L "$backup" ]; then
detail "Restoring the previous application release"
mv -- "$backup" "$destination"
(
cd "$destination"
docker compose up -d --build --remove-orphans >/dev/null 2>&1
) || true
failure "Previous application files were restored"
fi
elif [ "$previous_stopped" -eq 1 ] && [ -f "$destination/docker-compose.yml" ]; then
detail "Restarting the previous application release"
(
cd "$destination"
docker compose up -d --build --remove-orphans >/dev/null 2>&1
) || true
fi
exit "$status"
}
wait_for_service() {
service="$1"
elapsed=0
detail "Waiting for $service"
while [ "$elapsed" -lt "$health_timeout" ]; do
container_id=$(docker compose ps --all -q "$service" 2>/dev/null || true)
if [ -n "$container_id" ]; then
state=$(docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || true)
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true)
if [ "$state" = 'running' ] && { [ "$health" = 'healthy' ] || [ "$health" = 'none' ]; }; then
success "$service is $state ($health)"
return 0
fi
if [ "$state" = 'exited' ] || [ "$state" = 'dead' ]; then
failure "$service entered state: $state"
docker compose logs --no-color --tail 60 "$service" || true
return 1
fi
fi
sleep 2
elapsed=$((elapsed + 2))
done
failure "$service did not become healthy within ${health_timeout}s"
docker compose logs --no-color --tail 60 "$service" || true
return 1
}
trap rollback EXIT
trap 'exit 130' INT TERM
step '[remote 1/8] Checking Docker'
if ! command -v docker >/dev/null 2>&1; then
failure 'Docker is not installed on the NAS'
exit 1
fi
docker info >/dev/null 2>&1 || {
failure 'Docker is installed but the daemon is unavailable'
exit 1
}
docker compose version >/dev/null 2>&1 || {
failure 'The Docker Compose v2 plugin is not installed'
exit 1
}
success "$(docker --version)"
success "$(docker compose version)"
step '[remote 2/8] Extracting the release'
# Checked before anything is created: the staging directory, the swap and the backup all
# need write access to the parent, and "can't create directory" from BusyBox halfway
# through a deployment is a poor way to learn the account cannot write there.
mkdir -p -- "$parent" 2>/dev/null || true
if [ ! -d "$parent" ]; then
failure "$parent does not exist and could not be created by $(id -un)"
detail 'Pick a path this account can write to with -Destination'
exit 1
fi
if [ ! -w "$parent" ]; then
failure "$(id -un) cannot write to $parent"
detail 'Deploy somewhere this account owns, for example:'
detail ' .\deploy-server.ps1 -Destination /share/Docker/Memby'
detail "or grant this account write access to $parent"
exit 1
fi
rm -rf -- "$staging"
mkdir -- "$staging"
tar -xf - -C "$staging"
test -f "$staging/docker-compose.yml"
test -f "$staging/server/Dockerfile"
success 'Release extracted'
step '[remote 3/8] Installing configuration from .env.example'
# The local .env.example is the single source of truth for configuration and carries
# real values rather than placeholders. Every deployment overwrites the deployed .env
# with it, so neither a Git push nor an SSH edit is needed.
previous_password=''
if [ -f "$destination/.env" ]; then
previous_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$destination/.env" | head -n 1 | tr -d '\r')
# Kept beside the new one purely so a bad edit is recoverable by hand.
cp -- "$destination/.env" "$staging/.env.previous"
detail 'Previous .env saved as .env.previous'
fi
cp -- "$staging/.env.example" "$staging/.env"
success 'Installed .env from .env.example'
new_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$staging/.env" | head -n 1 | tr -d '\r')
admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$staging/.env" | head -n 1 | tr -d '\r')
emby_url=$(sed -n 's/^MEMBY_EMBY_URL=//p' "$staging/.env" | head -n 1 | tr -d '\r')
configured_port=$(sed -n 's/^MEMBY_PORT=//p' "$staging/.env" | head -n 1 | tr -d '\r')
if [ -z "$new_password" ]; then
failure 'POSTGRES_PASSWORD is empty in .env.example; Compose will refuse to start'
exit 1
fi
if [ -z "$emby_url" ]; then
failure 'MEMBY_EMBY_URL is empty in .env.example'
exit 1
fi
if [ -z "$admin_token" ]; then
failure 'MEMBY_ADMIN_TOKEN is empty in .env.example; refusing to deploy with /admin disabled'
exit 1
fi
if [ "$configured_port" != '32768' ]; then
failure "MEMBY_PORT must be 32768 for the mserver.sublogue.com reverse proxy (found: ${configured_port:-unset})"
exit 1
fi
success 'Required gateway configuration is present'
if [ -n "$previous_password" ] && [ "$previous_password" != "$new_password" ]; then
failure 'POSTGRES_PASSWORD differs from the deployed value'
detail 'The PostgreSQL volume is always preserved; restore the deployed password in .env.example'
detail 'Change the database credential separately with an explicit migration if required'
exit 1
fi
(
cd "$staging"
docker compose config --quiet
)
success 'Compose configuration is valid'
step '[remote 4/8] Pulling PostgreSQL and Redis'
(
cd "$staging"
docker compose pull postgres redis
)
success 'Dependency images are ready'
step '[remote 5/8] Building memby-server'
(
cd "$staging"
docker compose build --pull server
)
success 'Server image built'
step '[remote 6/8] Activating the release'
rm -rf -- "$backup"
if [ -e "$destination" ] || [ -L "$destination" ]; then
if [ -f "$destination/docker-compose.yml" ]; then
# Compose projects created by older releases may use a different project
# name. Stop them from their original directory before moving it so their
# published ports (especially 32768) are released for the new stack.
detail 'Stopping the previous Compose application'
(
cd "$destination"
docker compose down --remove-orphans
)
previous_stopped=1
success 'Previous Compose application stopped'
fi
mv -- "$destination" "$backup"
fi
mv -- "$staging" "$destination"
activated=1
success 'Release activated'
step '[remote 7/8] Starting the Compose stack'
cd "$destination"
docker compose up -d --remove-orphans
success 'Compose start command completed'
step '[remote 8/8] Waiting for healthy services'
wait_for_service postgres
wait_for_service redis
wait_for_service server
published_address=$(docker compose port server 32768 | head -n 1)
actual_port=${published_address##*:}
if [ "$actual_port" != '32768' ]; then
failure "Memby published the wrong NAS port: ${published_address:-none}"
exit 1
fi
server_container_id=$(docker compose ps -q server)
if ! docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$server_container_id" |
grep -q '^MEMBY_ADMIN_TOKEN=.'; then
failure 'MEMBY_ADMIN_TOKEN was not passed into the running server container'
exit 1
fi
success 'Runtime configuration includes the admin token'
printf '\n'
docker compose ps
printf '\n'
rm -rf -- "$backup"
activated=0
trap - EXIT INT TERM
success 'Remote deployment is healthy'
success 'Memby gateway: https://mserver.sublogue.com'
'@
$remoteCommand = $remoteCommand.Replace('__DESTINATION__', $Destination)
$remoteCommand = $remoteCommand.Replace('__HEALTH_TIMEOUT__', $HealthTimeoutSeconds.ToString())
# This file is edited on Windows, so the here-string above arrives with whatever line
# endings it was saved with. A remote shell reads a trailing carriage return as part
# of the token — 'set -eu\r' fails with "illegal option" before anything runs — so the
# script is normalised to LF here rather than depending on how it was saved.
$remoteCommand = $remoteCommand.Replace("`r`n", "`n").Replace("`r", "`n")
Write-Step "Deploying to $RemoteHost"
Write-Detail 'One SSH password prompt will appear'
Write-Detail 'Remote build output follows'
Write-Host ''
Send-ArchiveOverSsh -ArchivePath $archivePath -RemoteCommand $remoteCommand
$deploymentTimer.Stop()
Write-Host ''
Write-Success ("Deployment complete in {0:mm\:ss}" -f $deploymentTimer.Elapsed)
Write-Host ' Memby gateway: https://mserver.sublogue.com' -ForegroundColor White
Write-Host " NAS endpoint: http://${RemoteHost}:32768" -ForegroundColor DarkGray
Write-Host " Install path: ${RemoteHost}:$Destination" -ForegroundColor DarkGray
}
catch {
$deploymentTimer.Stop()
Write-Host ''
Write-Host "✗ Deployment stopped after $($deploymentTimer.Elapsed.ToString('mm\:ss'))" -ForegroundColor Red
Write-Host " $($_.Exception.Message)" -ForegroundColor Red
throw
}
finally {
if (Test-Path -LiteralPath $workDirectory) {
Remove-Item -LiteralPath $workDirectory -Recurse -Force
}
}
+201
View File
@@ -0,0 +1,201 @@
<#
.SYNOPSIS
Builds, verifies, and installs a signed Memby release on an Android TV.
.DESCRIPTION
Uses the permanent signing configuration stored in the current Windows user's
MEMBY_KEYSTORE environment variables. The APK is installed directly with ADB;
no Gitea release or update server is required.
.EXAMPLE
.\deploy-tv.ps1
.EXAMPLE
.\deploy-tv.ps1 -Device 10.0.0.238:35343 -SkipTests
#>
#Requires -Version 7.2
[CmdletBinding()]
param(
[Parameter()]
[ValidatePattern('^[A-Za-z0-9._:-]+$')]
[string] $Device = '10.0.0.238:35343',
[Parameter()]
[switch] $SkipTests
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
$packageName = 'com.ponzischeme89.memby'
function Invoke-Checked {
param(
[Parameter(Mandatory)]
[string] $FilePath,
[Parameter()]
[string[]] $Arguments = @()
)
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "'$FilePath' failed with exit code $LASTEXITCODE."
}
}
function Get-AndroidSdk {
if ($env:ANDROID_HOME -and (Test-Path -LiteralPath $env:ANDROID_HOME)) {
return $env:ANDROID_HOME
}
$propertiesPath = Join-Path $root 'local.properties'
if (Test-Path -LiteralPath $propertiesPath) {
$sdkLine = Get-Content -LiteralPath $propertiesPath |
Where-Object { $_ -match '^sdk\.dir=' } |
Select-Object -First 1
if ($sdkLine) {
$sdkPath = $sdkLine.Substring($sdkLine.IndexOf('=') + 1).
Replace('\:', ':').
Replace('\\', '\')
if (Test-Path -LiteralPath $sdkPath) {
return $sdkPath
}
}
}
throw 'Android SDK not found. Set ANDROID_HOME or sdk.dir in local.properties.'
}
function Import-UserSigningEnvironment {
foreach ($name in @(
'MEMBY_KEYSTORE',
'MEMBY_KEYSTORE_PASSWORD',
'MEMBY_KEY_ALIAS',
'MEMBY_KEY_PASSWORD'
)) {
$value = [Environment]::GetEnvironmentVariable($name, 'Process')
if ([string]::IsNullOrWhiteSpace($value)) {
$value = [Environment]::GetEnvironmentVariable($name, 'User')
}
if ([string]::IsNullOrWhiteSpace($value)) {
throw "Release signing variable $name is not configured."
}
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
}
if (-not (Test-Path -LiteralPath $env:MEMBY_KEYSTORE -PathType Leaf)) {
throw "Release keystore not found: $env:MEMBY_KEYSTORE"
}
}
Write-Host ''
Write-Host '╭─ Memby TV deployment' -ForegroundColor Magenta
Write-Host "│ Device $Device" -ForegroundColor DarkGray
Write-Host '│ Build signed release' -ForegroundColor DarkGray
Write-Host '╰─' -ForegroundColor Magenta
Write-Host ''
Import-UserSigningEnvironment
$sdk = Get-AndroidSdk
$adb = Join-Path $sdk 'platform-tools\adb.exe'
if (-not (Test-Path -LiteralPath $adb -PathType Leaf)) {
throw "ADB not found: $adb"
}
$buildTools = Get-ChildItem -LiteralPath (Join-Path $sdk 'build-tools') -Directory |
Sort-Object { [version]$_.Name } -Descending |
Select-Object -First 1
if (-not $buildTools) {
throw 'Android SDK Build Tools are not installed.'
}
$apkSigner = Join-Path $buildTools.FullName 'apksigner.bat'
if (-not (Test-Path -LiteralPath $apkSigner -PathType Leaf)) {
throw "APK signer not found: $apkSigner"
}
$jdkHome = 'C:\Program Files\Android\Android Studio\jbr'
if (Test-Path -LiteralPath $jdkHome) {
$env:JAVA_HOME = $jdkHome
}
Write-Host '● Building release APK' -ForegroundColor Cyan
$gradleArguments = @('--console=plain')
if (-not $SkipTests) {
$gradleArguments += 'testDebugUnitTest'
}
$gradleArguments += 'assembleRelease'
Invoke-Checked -FilePath (Join-Path $root 'gradlew.bat') -Arguments $gradleArguments
$apk = Join-Path $root 'app\build\outputs\apk\release\app-release.apk'
if (-not (Test-Path -LiteralPath $apk -PathType Leaf)) {
$unsigned = Join-Path $root 'app\build\outputs\apk\release\app-release-unsigned.apk'
if (Test-Path -LiteralPath $unsigned) {
throw 'Gradle produced an unsigned APK; deployment was stopped.'
}
throw "Release APK not found: $apk"
}
Write-Host ''
Write-Host '● Verifying release signature' -ForegroundColor Cyan
Invoke-Checked -FilePath $apkSigner -Arguments @(
'verify',
'--verbose',
'--print-certs',
$apk
)
Write-Host ''
Write-Host '● Connecting to Android TV' -ForegroundColor Cyan
& $adb connect $Device
if ($LASTEXITCODE -ne 0) {
throw "Unable to connect to Android TV at $Device."
}
Invoke-Checked -FilePath $adb -Arguments @('-s', $Device, 'get-state')
Write-Host ''
Write-Host '● Installing release' -ForegroundColor Cyan
Invoke-Checked -FilePath $adb -Arguments @(
'-s', $Device,
'install',
'-r',
'--no-streaming',
$apk
)
$packageDetails = & $adb -s $Device shell dumpsys package $packageName
if ($LASTEXITCODE -ne 0) {
throw 'The APK installed, but its package details could not be read.'
}
$versionName = (
$packageDetails |
Select-String -Pattern 'versionName=([^\s]+)' |
Select-Object -First 1
).Matches.Groups[1].Value
if ([string]::IsNullOrWhiteSpace($versionName)) {
throw "Installed package $packageName was not found."
}
$resolveOutput = & $adb -s $Device shell cmd package resolve-activity --brief `
-a android.intent.action.MAIN `
-c android.intent.category.LEANBACK_LAUNCHER `
$packageName
$component = $resolveOutput |
Where-Object { $_.Trim() -match '^[A-Za-z0-9._]+/[A-Za-z0-9._$]+$' } |
Select-Object -Last 1
if ($LASTEXITCODE -eq 0 -and $component) {
$component = $component.Trim()
Invoke-Checked -FilePath $adb -Arguments @(
'-s', $Device,
'shell', 'am', 'start',
'-n', $component
)
}
Write-Host ''
Write-Host "✓ Memby $versionName installed on $Device" -ForegroundColor Green
Write-Host " Package: $packageName" -ForegroundColor DarkGray
Write-Host " APK: $apk" -ForegroundColor DarkGray
+133
View File
@@ -0,0 +1,133 @@
package api
import (
"context"
"encoding/json"
"fmt"
"sort"
"time"
)
// Kinds are part of the wire contract: an unknown kind is rendered with the generic
// banner rather than dropped, so adding one never needs an app release.
const (
alertKindSonarrAired = "sonarr-aired"
// A TV shows one banner at a time; more than a few queued up is noise, not news.
maxAlerts = 3
)
// clientAlert is a short-lived, informational nudge delivered on the /v1/status poll —
// the only channel the app already listens to while it is open. It carries no action:
// the client slides it in, shows it for a few seconds and forgets it.
type clientAlert struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Message string `json:"message"`
ItemID string `json:"itemId,omitempty"`
ImageTag string `json:"imageTag,omitempty"`
AiredAt string `json:"airedAt,omitempty"`
}
// sonarrAiredAlerts reads the day's calendar through the same cache the airing-today row
// uses, so polling clients never cost a Sonarr request of their own.
func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
if s.sonarr == nil || s.cfg.SonarrAlertWindow <= 0 {
return nil
}
row, err := s.sonarrAiringTodayRow(ctx)
if err != nil {
s.log.Warn("sonarr alerts unavailable", "error", err)
return nil
}
if row == nil {
return nil
}
items := make([]sonarrScheduleItem, 0, len(row.Items))
for _, raw := range row.Items {
var item sonarrScheduleItem
if json.Unmarshal(raw, &item) != nil {
continue
}
items = append(items, item)
}
location := s.cfg.SonarrLocation
if location == nil {
location = time.Local
}
return buildSonarrAlerts(items, time.Now().In(location), s.cfg.SonarrAlertWindow)
}
// buildSonarrAlerts announces episodes that have aired but are not in Emby yet — the
// gap the viewer would otherwise experience as "it's Tuesday, so why isn't it there".
// Anything already downloaded is deliberately silent: it is on the home screen, which
// says it better than a banner would.
func buildSonarrAlerts(items []sonarrScheduleItem, now time.Time, window time.Duration) []clientAlert {
if window <= 0 {
return nil
}
type dated struct {
alert clientAlert
airs time.Time
}
found := make([]dated, 0, len(items))
for _, item := range items {
if item.MembyAirsAt == "" {
continue
}
airsAt, err := time.Parse(time.RFC3339, item.MembyAirsAt)
if err != nil {
continue
}
// Not yet aired, or aired so long ago that saying so is no longer news.
if airsAt.After(now) || now.Sub(airsAt) > window {
continue
}
message, ok := airedMessage(item, airsAt)
if !ok {
continue
}
found = append(found, dated{
alert: clientAlert{
ID: item.ID + ":aired",
Kind: alertKindSonarrAired,
Title: item.Name,
Message: message,
ItemID: item.ID,
ImageTag: item.ImageTags["Primary"],
AiredAt: item.MembyAirsAt,
},
airs: airsAt,
})
}
// Newest first: if several aired inside the window, the most recent is the one the
// viewer is most likely to be waiting on.
sort.SliceStable(found, func(i, j int) bool { return found[i].airs.After(found[j].airs) })
if len(found) > maxAlerts {
found = found[:maxAlerts]
}
alerts := make([]clientAlert, 0, len(found))
for _, entry := range found {
alerts = append(alerts, entry.alert)
}
return alerts
}
func airedMessage(item sonarrScheduleItem, airsAt time.Time) (string, bool) {
episode := item.MembyEpisodeCode
if item.MembyEpisodeTitle != "" {
episode = fmt.Sprintf("%s — %s", episode, item.MembyEpisodeTitle)
}
switch item.MembyAvailability {
case "downloading":
return fmt.Sprintf("%s aired at %s and is downloading now.", episode, airsAt.Format("3:04 PM")), true
case "awaiting":
return fmt.Sprintf("%s aired at %s and will be in Emby soon.", episode, airsAt.Format("3:04 PM")), true
default:
// available (already watchable) and unmonitored (never coming) both have
// nothing useful to announce.
return "", false
}
}
+80
View File
@@ -0,0 +1,80 @@
package api
import (
"strings"
"testing"
"time"
)
func airedItem(id, name, availability string, airsAt time.Time) sonarrScheduleItem {
return sonarrScheduleItem{
ID: id,
Name: name,
MembyEpisodeCode: "S02E04",
MembyEpisodeTitle: "The Crossing",
MembyAirsAt: airsAt.Format(time.RFC3339),
MembyAvailability: availability,
MembyAvailabilityText: availability,
ImageTags: map[string]string{"Primary": "sonarr"},
}
}
func TestBuildSonarrAlertsAnnouncesOnlyEpisodesNotYetInEmby(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 27, 21, 30, 0, 0, location)
aired := now.Add(-30 * time.Minute)
alerts := buildSonarrAlerts([]sonarrScheduleItem{
airedItem("sonarr:7:42", "Northbound", "awaiting", aired),
airedItem("sonarr:8:43", "Grabbed Show", "downloading", aired.Add(-time.Minute)),
airedItem("sonarr:9:44", "Already Here", "available", aired),
airedItem("sonarr:10:45", "Not Watched", "unmonitored", aired),
airedItem("sonarr:11:46", "Later Tonight", "upcoming", now.Add(90*time.Minute)),
airedItem("sonarr:12:47", "This Morning", "awaiting", now.Add(-8*time.Hour)),
}, now, 3*time.Hour)
if len(alerts) != 2 {
t.Fatalf("expected 2 alerts, got %d: %+v", len(alerts), alerts)
}
first := alerts[0]
if first.ID != "sonarr:7:42:aired" || first.Kind != alertKindSonarrAired {
t.Fatalf("unexpected alert identity: %+v", first)
}
if first.Title != "Northbound" || first.ItemID != "sonarr:7:42" || first.ImageTag != "sonarr" {
t.Fatalf("unexpected alert payload: %+v", first)
}
if !strings.Contains(first.Message, "S02E04 — The Crossing aired at 9:00 PM") ||
!strings.Contains(first.Message, "in Emby soon") {
t.Fatalf("unexpected awaiting message: %q", first.Message)
}
if !strings.Contains(alerts[1].Message, "downloading now") {
t.Fatalf("unexpected downloading message: %q", alerts[1].Message)
}
}
func TestBuildSonarrAlertsSortsNewestFirstAndCaps(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 27, 22, 0, 0, 0, location)
items := []sonarrScheduleItem{
airedItem("sonarr:1:1", "Oldest", "awaiting", now.Add(-100*time.Minute)),
airedItem("sonarr:2:2", "Newest", "awaiting", now.Add(-5*time.Minute)),
airedItem("sonarr:3:3", "Middle", "awaiting", now.Add(-40*time.Minute)),
airedItem("sonarr:4:4", "Older", "awaiting", now.Add(-80*time.Minute)),
}
alerts := buildSonarrAlerts(items, now, 3*time.Hour)
if len(alerts) != maxAlerts {
t.Fatalf("expected the list capped at %d, got %d", maxAlerts, len(alerts))
}
if alerts[0].Title != "Newest" || alerts[1].Title != "Middle" || alerts[2].Title != "Older" {
t.Fatalf("alerts are not newest-first: %+v", alerts)
}
}
func TestBuildSonarrAlertsDisabledByZeroWindow(t *testing.T) {
now := time.Date(2026, 7, 27, 22, 0, 0, 0, time.UTC)
items := []sonarrScheduleItem{airedItem("sonarr:1:1", "Northbound", "awaiting", now.Add(-time.Minute))}
if alerts := buildSonarrAlerts(items, now, 0); alerts != nil {
t.Fatalf("a zero window must disable alerts, got %+v", alerts)
}
}
+16
View File
@@ -0,0 +1,16 @@
package api
import (
"strings"
"testing"
)
func TestDetailFieldsIncludeEmbyPeople(t *testing.T) {
fields := strings.Split(fieldsDetail, ",")
for _, field := range fields {
if field == "People" {
return
}
}
t.Fatal("detail responses must request Emby's People field for cast metadata and portrait tags")
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"syscall"
"testing"
)
func TestExpectedClientDisconnect(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/images/1/primary", nil)
for _, err := range []error{
context.Canceled,
syscall.EPIPE,
syscall.ECONNRESET,
errors.New("write tcp: broken pipe"),
errors.New("client disconnected"),
} {
if !expectedClientDisconnect(req, err) {
t.Errorf("%q should be an expected client disconnect", err)
}
}
if expectedClientDisconnect(req, io.ErrUnexpectedEOF) {
t.Fatal("an upstream truncated image must remain a real warning")
}
}
func TestExpectedClientDisconnectUsesRequestContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := httptest.NewRequest(http.MethodGet, "/v1/images/1/primary", nil).WithContext(ctx)
if !expectedClientDisconnect(req, errors.New("opaque response writer error")) {
t.Fatal("a cancelled request should be treated as viewer cancellation")
}
}
func TestTaggedImageConditionalRequestSkipsUpstream(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/images/1/primary?tag=abc123", nil)
req.Header.Set("If-None-Match", `"abc123"`)
rec := httptest.NewRecorder()
if !writeNotModifiedForTag(rec, req, "abc123") {
t.Fatal("matching image tag should short-circuit")
}
if rec.Code != http.StatusNotModified {
t.Fatalf("status = %d, want 304", rec.Code)
}
if got := rec.Header().Get("ETag"); got != `"abc123"` {
t.Fatalf("etag = %q", got)
}
}
+36
View File
@@ -0,0 +1,36 @@
package api
import (
"log/slog"
"net/http"
"testing"
)
func TestRequestLogLevel(t *testing.T) {
tests := []struct {
path string
status int
want slog.Level
}{
{"/healthz", http.StatusOK, slog.LevelDebug},
{"/v1/status", http.StatusOK, slog.LevelDebug},
{"/v1/images/123/primary", http.StatusOK, slog.LevelDebug},
{"/v1/home", http.StatusOK, slog.LevelInfo},
{"/v1/images/123/primary", http.StatusNotFound, slog.LevelWarn},
{"/v1/home", http.StatusServiceUnavailable, slog.LevelError},
}
for _, test := range tests {
if got := requestLogLevel(test.path, test.status); got != test.want {
t.Errorf("requestLogLevel(%q, %d) = %v, want %v", test.path, test.status, got, test.want)
}
}
}
func TestClientLogValueIsNeverBlank(t *testing.T) {
if got := clientLogValue(""); got != "unknown" {
t.Fatalf("blank identity logged as %q", got)
}
if got := clientLogValue("0.1.60"); got != "0.1.60" {
t.Fatalf("reported identity changed to %q", got)
}
}
@@ -0,0 +1,25 @@
package api
import "testing"
func TestSubtitleMIME(t *testing.T) {
tests := map[string]string{
"srt": "application/x-subrip",
"subrip": "application/x-subrip",
"webvtt": "text/vtt",
"ass": "text/x-ssa",
"mov_text": "application/x-quicktime-tx3g",
"pgssub": "",
}
for codec, want := range tests {
if got := subtitleMIME(codec, ""); got != want {
t.Errorf("subtitleMIME(%q) = %q, want %q", codec, got, want)
}
}
}
func TestSubtitleMIMEFallsBackToDeliveryExtension(t *testing.T) {
if got := subtitleMIME("", "https://emby.example/subtitles/4/stream.vtt?api_key=x"); got != "text/vtt" {
t.Fatalf("subtitleMIME delivery fallback = %q", got)
}
}
+149
View File
@@ -0,0 +1,149 @@
package api
import (
"crypto/subtle"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/ponzischeme89/memby/server/internal/appupdate"
)
const maxReleaseSize = 250 << 20
var (
releaseVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
releaseFilenamePattern = regexp.MustCompile(`^memby-\d+\.\d+\.\d+\.apk$`)
)
// releasePublishAuth is deliberately separate from adminAuth: CI can publish an APK but
// cannot take the service offline, force an update, or read household analytics.
func (s *Server) releasePublishAuth(h http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.cfg.ReleasePublishToken == "" {
http.NotFound(w, r)
return
}
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.ReleasePublishToken)) != 1 {
writeError(w, http.StatusUnauthorized, "invalid release token")
return
}
h(w, r)
})
}
// handleReleasePublish accepts the signed APK produced by Gitea Actions, persists it,
// and atomically makes it the version offered to TVs.
func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxReleaseSize)
if err := r.ParseMultipartForm(16 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid release upload")
return
}
version := strings.TrimSpace(r.FormValue("version"))
if !releaseVersionPattern.MatchString(version) {
writeError(w, http.StatusBadRequest, "version must look like 0.1.54")
return
}
current := s.updatePolicy.get()
if current.LatestVersion != "" &&
appupdate.CompareVersions(version, current.LatestVersion) < 0 {
writeError(w, http.StatusConflict, "refusing to publish an older version")
return
}
source, _, err := r.FormFile("apk")
if err != nil {
writeError(w, http.StatusBadRequest, "signed APK is required")
return
}
defer source.Close()
if err := os.MkdirAll(s.cfg.ReleaseDir, 0o750); err != nil {
s.log.Error("release directory unavailable", "error", err)
writeError(w, http.StatusInternalServerError, "release storage unavailable")
return
}
temp, err := os.CreateTemp(s.cfg.ReleaseDir, ".memby-upload-*")
if err != nil {
s.log.Error("release temp file failed", "error", err)
writeError(w, http.StatusInternalServerError, "release storage unavailable")
return
}
tempName := temp.Name()
defer os.Remove(tempName)
written, copyErr := io.Copy(temp, source)
closeErr := temp.Close()
if copyErr != nil || closeErr != nil || written < 4 {
writeError(w, http.StatusBadRequest, "could not store APK")
return
}
// APKs are ZIP archives. This catches accidentally uploaded logs or HTML error pages
// before they become an update every TV is invited to install.
stored, err := os.Open(tempName)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not verify APK")
return
}
var magic [4]byte
_, readErr := io.ReadFull(stored, magic[:])
stored.Close()
if readErr != nil || string(magic[:2]) != "PK" {
writeError(w, http.StatusBadRequest, "uploaded file is not an APK")
return
}
filename := fmt.Sprintf("memby-%s.apk", version)
destination := filepath.Join(s.cfg.ReleaseDir, filename)
if err := os.Rename(tempName, destination); err != nil {
s.log.Error("release publish rename failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not publish APK")
return
}
if err := os.Chmod(destination, 0o640); err != nil {
s.log.Warn("release permissions could not be tightened", "error", err)
}
policy := appupdate.Policy{
Enabled: true,
LatestVersion: version,
MinimumVersion: current.MinimumVersion,
DownloadURL: s.cfg.PublicURL + "/updates/" + filename,
Notes: strings.TrimSpace(r.FormValue("notes")),
}
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
s.log.Error("release policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be saved")
return
}
if err := s.LoadUpdatePolicy(r.Context()); err != nil {
s.log.Error("release policy reload failed", "error", err)
writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be loaded")
return
}
s.log.Info("release published", "version", version, "bytes", written, "file", filename)
writeJSON(w, http.StatusCreated, s.updatePolicy.get())
}
// handleReleaseDownload serves immutable, signed APKs. They carry no household secrets,
// so downloads do not need a TV session and continue working through Android's installer.
func (s *Server) handleReleaseDownload(w http.ResponseWriter, r *http.Request) {
filename := r.PathValue("filename")
if !releaseFilenamePattern.MatchString(filename) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeFile(w, r, filepath.Join(s.cfg.ReleaseDir, filename))
}
+63
View File
@@ -0,0 +1,63 @@
package api
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
)
func TestReleasePublishAuth(t *testing.T) {
handler := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
t.Run("disabled is hidden", func(t *testing.T) {
s := &Server{cfg: config.Config{}}
rec := httptest.NewRecorder()
s.releasePublishAuth(handler).ServeHTTP(
rec,
httptest.NewRequest(http.MethodPost, "/admin/api/release", nil),
)
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404", rec.Code)
}
})
t.Run("valid bearer token is accepted", func(t *testing.T) {
s := &Server{cfg: config.Config{ReleasePublishToken: "release-secret"}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/api/release", nil)
req.Header.Set("Authorization", "Bearer release-secret")
s.releasePublishAuth(handler).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("got %d, want 204", rec.Code)
}
})
}
func TestReleaseDownloadOnlyServesVersionedAPKs(t *testing.T) {
dir := t.TempDir()
payload := []byte("PK signed apk")
if err := os.WriteFile(filepath.Join(dir, "memby-0.1.54.apk"), payload, 0o600); err != nil {
t.Fatal(err)
}
s := &Server{cfg: config.Config{ReleaseDir: dir}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/updates/memby-0.1.54.apk", nil)
req.SetPathValue("filename", "memby-0.1.54.apk")
s.handleReleaseDownload(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != string(payload) {
t.Fatalf("valid release response = %d %q", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/updates/../secrets", nil)
req.SetPathValue("filename", "../secrets")
s.handleReleaseDownload(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("invalid filename got %d, want 404", rec.Code)
}
}
+345
View File
@@ -0,0 +1,345 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
const sonarrCalendarCachePrefix = "sonarr:calendar:"
const sonarrPrerollCachePrefix = "sonarr:preroll:"
type prerollScheduleResponse struct {
Today []prerollScheduleEntry `json:"today"`
ThisWeek []prerollScheduleEntry `json:"thisWeek"`
}
type prerollScheduleEntry struct {
Series string `json:"series"`
Episode string `json:"episode"`
EpisodeCode string `json:"episodeCode"`
Schedule string `json:"schedule"`
Availability string `json:"availability,omitempty"`
}
func (s *Server) handlePreroll(w http.ResponseWriter, r *http.Request, _ store.Session) {
if s.sonarr == nil {
writeJSON(w, http.StatusOK, emptyPrerollSchedule())
return
}
schedule, err := s.sonarrPrerollSchedule(r.Context())
if err != nil {
// Pre-roll is decorative and must never become a playback dependency.
s.log.Warn("preroll schedule unavailable", "error", err)
writeJSON(w, http.StatusOK, emptyPrerollSchedule())
return
}
writeJSON(w, http.StatusOK, schedule)
}
func (s *Server) sonarrPrerollSchedule(ctx context.Context) (prerollScheduleResponse, error) {
location := s.cfg.SonarrLocation
if location == nil {
location = time.Local
}
now := time.Now().In(location)
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
key := sonarrPrerollCachePrefix + dayStart.Format("2006-01-02")
if raw, err := s.cache.Get(ctx, key); err == nil {
var cached prerollScheduleResponse
if json.Unmarshal(raw, &cached) == nil {
return normalizePrerollSchedule(cached), nil
}
}
s.sonarrMu.Lock()
defer s.sonarrMu.Unlock()
if raw, err := s.cache.Get(ctx, key); err == nil {
var cached prerollScheduleResponse
if json.Unmarshal(raw, &cached) == nil {
return normalizePrerollSchedule(cached), nil
}
}
episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, 7))
if err != nil {
return emptyPrerollSchedule(), err
}
schedule := buildPrerollSchedule(episodes, now, location)
if raw, err := json.Marshal(schedule); err == nil {
if cacheErr := s.cache.Set(ctx, key, raw, s.cfg.SonarrTTL); cacheErr != nil {
s.log.Warn("preroll schedule cache write failed", "error", cacheErr)
}
}
return schedule, nil
}
func buildPrerollSchedule(
episodes []sonarr.Episode,
now time.Time,
location *time.Location,
) prerollScheduleResponse {
sort.SliceStable(episodes, func(i, j int) bool {
if episodes[i].AirDateUTC == nil {
return false
}
if episodes[j].AirDateUTC == nil {
return true
}
return episodes[i].AirDateUTC.Before(*episodes[j].AirDateUTC)
})
todayEnd := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location).AddDate(0, 0, 1)
result := emptyPrerollSchedule()
for _, episode := range episodes {
if episode.AirDateUTC == nil {
continue
}
airTime := episode.AirDateUTC.In(location)
entry := prerollScheduleEntry{
Series: episode.Series.Title,
Episode: episode.Title,
EpisodeCode: fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber),
Availability: prerollAvailability(episode),
}
if airTime.Before(todayEnd) {
entry.Schedule = airTime.Format("3:04 PM")
if len(result.Today) < 4 {
result.Today = append(result.Today, entry)
}
} else {
entry.Schedule = airTime.Format("Monday · 3:04 PM")
if len(result.ThisWeek) < 6 {
result.ThisWeek = append(result.ThisWeek, entry)
}
}
}
return result
}
func prerollAvailability(episode sonarr.Episode) string {
switch {
case episode.HasFile:
return "Downloaded"
case episode.Grabbed:
return "Downloading"
default:
return ""
}
}
func emptyPrerollSchedule() prerollScheduleResponse {
return prerollScheduleResponse{
Today: []prerollScheduleEntry{},
ThisWeek: []prerollScheduleEntry{},
}
}
func normalizePrerollSchedule(value prerollScheduleResponse) prerollScheduleResponse {
if value.Today == nil {
value.Today = []prerollScheduleEntry{}
}
if value.ThisWeek == nil {
value.ThisWeek = []prerollScheduleEntry{}
}
return value
}
// sonarrScheduleItem deliberately looks enough like an Emby item to reuse the fast home
// card renderer, while its Memby fields mark it as informational and non-playable.
type sonarrScheduleItem struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
Overview string `json:"Overview,omitempty"`
ProductionYear int `json:"ProductionYear,omitempty"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
Genres []string `json:"Genres"`
ImageTags map[string]string `json:"ImageTags"`
BackdropImageTags []string `json:"BackdropImageTags"`
MembySource string `json:"MembySource"`
MembyEpisodeTitle string `json:"MembyEpisodeTitle"`
MembyEpisodeCode string `json:"MembyEpisodeCode"`
MembyAirsAt string `json:"MembyAirsAt,omitempty"`
MembyAddedAt string `json:"MembyAddedAt,omitempty"`
MembyAirLabel string `json:"MembyAirLabel"`
MembyAvailability string `json:"MembyAvailability"`
MembyAvailabilityText string `json:"MembyAvailabilityText"`
MembyPlayable bool `json:"MembyPlayable"`
}
func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) {
if s.sonarr == nil {
return nil, nil
}
location := s.cfg.SonarrLocation
if location == nil {
location = time.Local
}
now := time.Now().In(location)
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
cacheKey := sonarrCalendarCachePrefix + dayStart.Format("2006-01-02")
if row := s.cachedSonarrRow(ctx, cacheKey); row != nil {
return row, nil
}
// A shared lock prevents several users opening the app together from stampeding
// Sonarr on the one cache miss each day.
s.sonarrMu.Lock()
defer s.sonarrMu.Unlock()
if row := s.cachedSonarrRow(ctx, cacheKey); row != nil {
return row, nil
}
episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, 1))
if err != nil {
return nil, err
}
row, err := buildSonarrRow(episodes, now, location)
if err != nil {
return nil, err
}
body, err := json.Marshal(row)
if err == nil {
if cacheErr := s.cache.Set(ctx, cacheKey, body, s.cfg.SonarrTTL); cacheErr != nil {
s.log.Warn("sonarr calendar cache write failed", "error", cacheErr)
}
}
return row, nil
}
func (s *Server) cachedSonarrRow(ctx context.Context, key string) *recommend.Row {
raw, err := s.cache.Get(ctx, key)
if err != nil {
return nil
}
var row recommend.Row
if json.Unmarshal(raw, &row) != nil {
return nil
}
return &row
}
func buildSonarrRow(episodes []sonarr.Episode, now time.Time, location *time.Location) (*recommend.Row, error) {
sort.SliceStable(episodes, func(i, j int) bool {
if episodes[i].AirDateUTC == nil {
return false
}
if episodes[j].AirDateUTC == nil {
return true
}
return episodes[i].AirDateUTC.Before(*episodes[j].AirDateUTC)
})
items := make([]json.RawMessage, 0, len(episodes))
for _, episode := range episodes {
item := toSonarrScheduleItem(episode, now, location)
raw, err := json.Marshal(item)
if err != nil {
return nil, err
}
items = append(items, raw)
}
return &recommend.Row{
ID: "sonarr-airing-today",
Title: "Shows airing today",
Kind: "schedule",
Items: items,
}, nil
}
func toSonarrScheduleItem(episode sonarr.Episode, now time.Time, location *time.Location) sonarrScheduleItem {
seriesID := episode.SeriesID
if episode.Series.ID > 0 {
seriesID = episode.Series.ID
}
item := sonarrScheduleItem{
ID: fmt.Sprintf("sonarr:%d:%d", seriesID, episode.ID),
Name: episode.Series.Title,
Type: "MembySonarrEpisode",
Overview: episode.Overview,
ProductionYear: episode.Series.Year,
RunTimeTicks: int64(episode.Runtime) * 600_000_000,
Genres: nonNilStrings(episode.Series.Genres),
ImageTags: map[string]string{},
BackdropImageTags: []string{},
MembySource: "sonarr",
MembyEpisodeTitle: episode.Title,
MembyEpisodeCode: fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber),
MembyPlayable: false,
}
if hasCover(episode.Series.Images, "poster") {
item.ImageTags["Primary"] = "sonarr"
}
if hasCover(episode.Series.Images, "fanart") {
item.BackdropImageTags = []string{"sonarr"}
}
if episode.AirDateUTC != nil {
airTime := episode.AirDateUTC.In(location)
item.MembyAirsAt = airTime.Format(time.RFC3339)
if airTime.After(now) {
item.MembyAirLabel = "Airs today at " + airTime.Format("3:04 PM")
} else {
item.MembyAirLabel = "Aired today at " + airTime.Format("3:04 PM")
}
} else {
item.MembyAirLabel = "Airs today"
}
addedAt := episodeFileAddedAt(episode)
switch {
case episode.HasFile:
item.MembyAvailability = "available"
item.MembyAvailabilityText = "Downloaded"
if addedAt != nil {
localAdded := addedAt.In(location)
item.MembyAddedAt = localAdded.Format(time.RFC3339)
item.MembyAvailabilityText = "Added at " + localAdded.Format("3:04 PM")
}
case episode.Grabbed:
item.MembyAvailability = "downloading"
item.MembyAvailabilityText = "Downloading"
case !episode.Monitored:
item.MembyAvailability = "unmonitored"
item.MembyAvailabilityText = "Not monitored"
case episode.AirDateUTC != nil && episode.AirDateUTC.Before(now):
item.MembyAvailability = "awaiting"
item.MembyAvailabilityText = "Awaiting download"
default:
item.MembyAvailability = "upcoming"
item.MembyAvailabilityText = "Upcoming"
}
return item
}
func episodeFileAddedAt(episode sonarr.Episode) *time.Time {
if episode.EpisodeFile == nil {
return nil
}
return episode.EpisodeFile.DateAdded
}
func hasCover(images []sonarr.Image, coverType string) bool {
for _, image := range images {
if strings.EqualFold(image.CoverType, coverType) {
return true
}
}
return false
}
func nonNilStrings(values []string) []string {
if values == nil {
return []string{}
}
return values
}
+79
View File
@@ -0,0 +1,79 @@
package api
import (
"encoding/json"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/sonarr"
)
func TestBuildSonarrRowIncludesScheduleAndAddedState(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
air := time.Date(2026, 7, 27, 8, 0, 0, 0, time.UTC)
added := time.Date(2026, 7, 27, 8, 12, 0, 0, time.UTC)
row, err := buildSonarrRow([]sonarr.Episode{{
ID: 42,
SeriesID: 7,
SeasonNumber: 2,
EpisodeNumber: 4,
Title: "The Crossing",
AirDateUTC: &air,
HasFile: true,
Monitored: true,
EpisodeFile: &sonarr.EpisodeFile{DateAdded: &added},
Series: sonarr.Series{
ID: 7,
Title: "Northbound",
Images: []sonarr.Image{
{CoverType: "poster"},
{CoverType: "fanart"},
},
},
}}, time.Date(2026, 7, 27, 21, 0, 0, 0, location), location)
if err != nil {
t.Fatal(err)
}
if row.ID != "sonarr-airing-today" || row.Kind != "schedule" || len(row.Items) != 1 {
t.Fatalf("unexpected row: %+v", row)
}
var item sonarrScheduleItem
if err := json.Unmarshal(row.Items[0], &item); err != nil {
t.Fatal(err)
}
if item.ID != "sonarr:7:42" || item.MembyEpisodeCode != "S02E04" {
t.Fatalf("unexpected identity: %+v", item)
}
if item.MembyAvailability != "available" || item.MembyAvailabilityText != "Added at 8:12 PM" {
t.Fatalf("unexpected availability: %+v", item)
}
if item.ImageTags["Primary"] == "" || len(item.BackdropImageTags) != 1 {
t.Fatalf("artwork was not exposed: %+v", item)
}
}
func TestBuildPrerollScheduleSplitsTodayAndWeek(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 28, 10, 0, 0, 0, location)
today := time.Date(2026, 7, 28, 20, 30, 0, 0, location).UTC()
thursday := time.Date(2026, 7, 30, 19, 0, 0, 0, location).UTC()
schedule := buildPrerollSchedule([]sonarr.Episode{
{
SeasonNumber: 1, EpisodeNumber: 4, Title: "Tonight",
AirDateUTC: &today, Series: sonarr.Series{Title: "Northbound"},
},
{
SeasonNumber: 2, EpisodeNumber: 1, Title: "Later",
AirDateUTC: &thursday, HasFile: true, Series: sonarr.Series{Title: "Harbour"},
},
}, now, location)
if len(schedule.Today) != 1 || schedule.Today[0].Series != "Northbound" ||
schedule.Today[0].Schedule != "8:30 PM" {
t.Fatalf("unexpected today schedule: %+v", schedule.Today)
}
if len(schedule.ThisWeek) != 1 || schedule.ThisWeek[0].EpisodeCode != "S02E01" ||
schedule.ThisWeek[0].Availability != "Downloaded" {
t.Fatalf("unexpected week schedule: %+v", schedule.ThisWeek)
}
}
+28
View File
@@ -0,0 +1,28 @@
package config
import "testing"
func TestDefaultClientAllowanceIsOne(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_MAX_CLIENTS_PER_USER", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.MaxClientsPerUser != 1 {
t.Fatalf("MaxClientsPerUser = %d, want 1", cfg.MaxClientsPerUser)
}
}
func TestTracearrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_TRACEARR_URL", "http://tracearr")
t.Setenv("MEMBY_TRACEARR_API_KEY", "")
if _, err := Load(); err == nil {
t.Fatal("expected incomplete Tracearr configuration to fail")
}
}
@@ -0,0 +1,27 @@
package emby
import (
"net/url"
"testing"
"time"
)
func TestSubtitleURLUsesCanonicalVTTEndpoint(t *testing.T) {
client := New("http://emby:8096", "https://emby.example", "Memby", time.Second)
got := client.SubtitleURL(
Credentials{Token: "a b"},
"item id",
"source/id",
4,
)
parsed, err := url.Parse(got)
if err != nil {
t.Fatal(err)
}
if parsed.EscapedPath() != "/Videos/item%20id/source%2Fid/Subtitles/4/Stream.vtt" {
t.Fatalf("unexpected subtitle path %q", parsed.EscapedPath())
}
if parsed.Query().Get("api_key") != "a b" {
t.Fatalf("subtitle token was not preserved")
}
}
+749
View File
@@ -0,0 +1,749 @@
// Package foryou keeps Tracearr-derived recommendation data warm in PostgreSQL.
//
// Imports and ranking happen away from television requests. PostgreSQL is also the
// queue: dirty_since records work that still needs doing, so a restart cannot lose it.
package foryou
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
const (
importPageSize = 100
incrementalMaxPages = 10
incrementalMinPages = 2
unchangedPagesToStop = 2
preparedPoolReadLimit = 240
preparedRowSize = 20
)
type ImportResult struct {
Kind string `json:"kind"`
Pages int `json:"pages"`
Seen int `json:"seen"`
Changed int `json:"changed"`
Removed int64 `json:"removed"`
Duration time.Duration `json:"-"`
DurationMs int64 `json:"durationMs"`
}
type Service struct {
store *store.Store
tracearr *tracearr.Client
engine *recommend.Engine
emby *emby.Client
serviceCred emby.Credentials
log *slog.Logger
minRebuildAge time.Duration
refreshAge time.Duration
mu sync.Mutex
importRunning bool
building map[string]bool
}
// ConfigureHouseholdUsers enables background preparation for every enabled Emby user.
// The service token is kept server-side and is only used for read-only profile building.
func (s *Service) ConfigureHouseholdUsers(client *emby.Client, cred emby.Credentials) {
s.emby = client
s.serviceCred = cred
}
func New(
st *store.Store,
tracearrClient *tracearr.Client,
engine *recommend.Engine,
log *slog.Logger,
minRebuildAge, refreshAge time.Duration,
) *Service {
return &Service{
store: st, tracearr: tracearrClient, engine: engine, log: log,
minRebuildAge: minRebuildAge, refreshAge: refreshAge,
building: map[string]bool{},
}
}
func (s *Service) Running() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.importRunning || len(s.building) > 0
}
func (s *Service) Stats(ctx context.Context) (store.ForYouStats, error) {
return s.store.ForYouStats(ctx)
}
func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, resultErr error) {
s.mu.Lock()
if s.importRunning {
s.mu.Unlock()
return result, errors.New("for you: a Tracearr import is already running")
}
s.importRunning = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.importRunning = false
s.mu.Unlock()
}()
started := time.Now().UTC()
result.Kind = "incremental"
count, err := s.store.TracearrSessionCount(ctx)
if err != nil {
return result, err
}
if count == 0 {
full = true
}
if full {
result.Kind = "full"
}
state, err := s.store.TracearrImportState(ctx)
if err != nil {
return result, err
}
defer func() {
now := time.Now().UTC()
if resultErr != nil {
state.LastError = resultErr.Error()
} else {
state.LastError = ""
state.LastIncrementalAt = &now
if full {
state.LastFullAt = &now
}
}
if err := s.store.SetTracearrImportState(context.WithoutCancel(ctx), state); err != nil {
s.log.Error("could not record Tracearr import state", "error", err)
}
result.Duration = time.Since(started)
result.DurationMs = result.Duration.Milliseconds()
}()
unchangedPages := 0
for pageNumber := 1; ; pageNumber++ {
page, err := s.tracearr.Page(ctx, pageNumber, importPageSize)
if err != nil {
return result, err
}
result.Pages++
result.Seen += len(page.Data)
imported := make([]store.TracearrSession, 0, len(page.Data))
keys := make([]store.TracearrSessionKey, 0, len(page.Data))
for _, session := range page.Data {
value, ok := importedSession(session, s.tracearr.ConfiguredServerID())
if !ok {
continue
}
imported = append(imported, value)
keys = append(keys, store.TracearrSessionKey{
ServerID: value.ServerID, SessionID: value.SessionID,
})
}
current, err := s.store.TracearrFingerprints(ctx, keys)
if err != nil {
return result, err
}
pageChanged := false
for _, session := range imported {
key := store.TracearrSessionKey{
ServerID: session.ServerID, SessionID: session.SessionID,
}
if !bytes.Equal(current[key], session.SourceFingerprint) {
pageChanged = true
result.Changed++
}
}
if err := s.store.UpsertTracearrSessions(ctx, imported, started); err != nil {
return result, err
}
if pageChanged {
unchangedPages = 0
} else {
unchangedPages++
}
reachedEnd := len(page.Data) == 0 ||
(page.Meta.Total > 0 && pageNumber*importPageSize >= page.Meta.Total)
if full && reachedEnd {
break
}
if !full && (reachedEnd ||
(pageNumber >= incrementalMinPages && unchangedPages >= unchangedPagesToStop) ||
pageNumber >= incrementalMaxPages) {
break
}
}
if full {
removed, err := s.store.DeleteTracearrSessionsNotSeenSince(
ctx, s.tracearr.ConfiguredServerID(), started,
)
if err != nil {
return result, err
}
result.Removed = removed
}
if result.Changed > 0 || result.Removed > 0 {
if err := s.store.MarkAllForYouProfilesDirty(ctx); err != nil {
return result, err
}
users, err := s.store.ActiveRecommendationUsers(ctx)
if err != nil {
return result, err
}
for _, user := range users {
if err := s.store.MarkForYouDirty(ctx, user.EmbyUserID, user.Username); err != nil {
return result, err
}
}
}
s.log.Info("Tracearr import finished",
"kind", result.Kind, "pages", result.Pages, "seen", result.Seen,
"changed", result.Changed, "removed", result.Removed)
return result, nil
}
func (s *Service) Rebuild(ctx context.Context, sess store.Session, force bool) error {
if !s.beginBuild(sess.EmbyUserID) {
return nil
}
defer s.endBuild(sess.EmbyUserID)
_, poolBuiltAt, dirtySince, err := s.store.ForYouProfileTimes(ctx, sess.EmbyUserID)
if err != nil {
return err
}
if !force && poolBuiltAt != nil && time.Since(*poolBuiltAt) < s.minRebuildAge {
return nil
}
if !force && dirtySince == nil && poolBuiltAt != nil && time.Since(*poolBuiltAt) < s.refreshAge {
return nil
}
tracearrUserID, storedUsername, err := s.store.ForYouTracearrIdentity(ctx, sess.EmbyUserID)
if err != nil {
return err
}
username := strings.TrimSpace(sess.Username)
if username == "" {
username = storedUsername
}
imported, err := s.store.TracearrSessionsForUser(ctx, tracearrUserID, username)
if err != nil {
return err
}
sessions := make([]tracearr.Session, 0, len(imported))
for _, value := range imported {
sessions = append(sessions, tracearrSession(value))
}
result, err := s.engine.PrepareForYou(ctx, credentials(sess), username, sessions)
if err != nil {
_ = s.store.SetForYouError(context.WithoutCancel(ctx), sess.EmbyUserID, err)
return err
}
profile, candidates, err := storedResult(sess.EmbyUserID, time.Now().UTC(), result)
if err != nil {
return err
}
for _, mapping := range result.Mappings {
if err := s.store.UpdateTracearrSessionMapping(ctx, store.TracearrSessionKey{
ServerID: mapping.ServerID, SessionID: mapping.SessionID,
}, mapping.ItemID, mapping.SeriesID); err != nil {
return err
}
}
if err := s.store.ReplaceForYouPool(ctx, profile, candidates); err != nil {
_ = s.store.SetForYouError(context.WithoutCancel(ctx), sess.EmbyUserID, err)
return err
}
s.log.Info("For You pool rebuilt",
"user", sess.EmbyUserID, "sessions", len(sessions), "candidates", len(candidates))
return nil
}
func (s *Service) PreparedRows(
ctx context.Context,
sess store.Session,
minutes int,
) ([]recommend.Row, bool, bool, error) {
items, builtAt, err := s.store.PreparedForYou(
ctx, sess.EmbyUserID, minutes, preparedPoolReadLimit,
)
if err != nil {
return nil, false, false, err
}
if len(items) == 0 {
return nil, false, builtAt != nil && time.Since(*builtAt) >= s.refreshAge, nil
}
rows := buildPreparedRows(items, minutes, s.engine.MinRowItems)
stale := builtAt == nil || time.Since(*builtAt) >= s.refreshAge
return rows, len(rows) > 0, stale, nil
}
func (s *Service) MarkDirty(ctx context.Context, sess store.Session) {
if err := s.store.MarkForYouDirty(ctx, sess.EmbyUserID, sess.Username); err != nil {
s.log.Warn("could not mark For You dirty", "user", sess.EmbyUserID, "error", err)
}
}
func (s *Service) RefreshAsync(sess store.Session, force bool) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := s.Rebuild(ctx, sess, force); err != nil {
s.log.Warn("For You background rebuild failed", "user", sess.EmbyUserID, "error", err)
}
}()
}
func (s *Service) RebuildAll(ctx context.Context, force bool) error {
users, err := s.recommendationUsers(ctx)
if err != nil {
return err
}
for _, user := range users {
if err := s.Rebuild(ctx, user, force); err != nil {
s.log.Warn("For You user rebuild failed", "user", user.EmbyUserID, "error", err)
}
}
return nil
}
func (s *Service) MarkAllDirty(ctx context.Context) {
if err := s.store.MarkAllForYouProfilesDirty(ctx); err != nil {
s.log.Warn("could not mark For You profiles dirty", "error", err)
}
users, err := s.store.ActiveRecommendationUsers(ctx)
if err != nil {
s.log.Warn("could not list For You users", "error", err)
return
}
for _, user := range users {
s.MarkDirty(ctx, user)
}
}
func (s *Service) recommendationUsers(ctx context.Context) ([]store.Session, error) {
active, err := s.store.ActiveRecommendationUsers(ctx)
if err != nil {
return nil, err
}
byID := make(map[string]store.Session, len(active))
for _, user := range active {
byID[user.EmbyUserID] = user
}
if s.emby == nil || strings.TrimSpace(s.serviceCred.Token) == "" {
return active, nil
}
embyUsers, err := s.emby.Users(ctx, s.serviceCred)
if err != nil {
s.log.Warn("Emby household users unavailable; using signed-in users", "error", err)
return active, nil
}
tracearrUsers, traceErr := s.allTracearrUsers(ctx)
if traceErr != nil {
s.log.Warn("Tracearr users unavailable; preparing Emby-only profiles", "error", traceErr)
}
traceByName := map[string]tracearr.User{}
for _, user := range tracearrUsers {
key := strings.ToLower(strings.TrimSpace(user.Username))
current, exists := traceByName[key]
if key != "" && (!exists || user.SessionCount > current.SessionCount) {
traceByName[key] = user
}
}
for _, user := range embyUsers {
if user.Policy.IsDisabled || strings.TrimSpace(user.ID) == "" {
continue
}
matched := traceByName[strings.ToLower(strings.TrimSpace(user.Name))]
if traceErr == nil {
if err := s.store.MatchRecommendationUser(
ctx, user.ID, user.Name, matched.ID, matched.Username,
); err != nil {
return nil, err
}
} else if err := s.store.MarkForYouDirty(ctx, user.ID, user.Name); err != nil {
return nil, err
}
byID[user.ID] = store.Session{
EmbyUserID: user.ID,
EmbyToken: s.serviceCred.Token,
Username: user.Name,
DeviceID: "memby-for-you-builder",
DeviceName: "Memby For You builder",
}
}
out := make([]store.Session, 0, len(byID))
for _, user := range byID {
out = append(out, user)
}
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i].Username) < strings.ToLower(out[j].Username)
})
return out, nil
}
func (s *Service) allTracearrUsers(ctx context.Context) ([]tracearr.User, error) {
const pageSize = 100
out := []tracearr.User{}
for pageNumber := 1; ; pageNumber++ {
page, err := s.tracearr.Users(ctx, pageNumber, pageSize)
if err != nil {
return nil, err
}
out = append(out, page.Data...)
if len(page.Data) == 0 || page.Meta.Total <= pageNumber*pageSize {
return out, nil
}
}
}
func buildPreparedRows(
items []store.PreparedForYouItem,
minutes, minRowItems int,
) []recommend.Row {
if len(items) == 0 {
return nil
}
used := map[string]bool{}
rows := make([]recommend.Row, 0, 6)
appendRowWithMinimum := func(
id, title string,
candidates []store.PreparedForYouItem,
minimum int,
) bool {
selected := make([]store.PreparedForYouItem, 0, preparedRowSize)
for _, item := range candidates {
if used[item.ItemID] {
continue
}
selected = append(selected, item)
if len(selected) == preparedRowSize {
break
}
}
if len(selected) < minimum {
return false
}
rowItems := make([]json.RawMessage, 0, len(selected))
for _, item := range selected {
used[item.ItemID] = true
rowItems = append(rowItems, recommend.EnrichPreparedRecommendation(
item.Payload, item.RecommendationReason, item.CompatibilityLabel, minutes,
))
}
rows = append(rows, recommend.Row{
ID: id, Title: title, Kind: "for-you", Items: rowItems,
})
return true
}
appendRow := func(id, title string, candidates []store.PreparedForYouItem) bool {
return appendRowWithMinimum(id, title, candidates, minRowItems)
}
pickups := make([]store.PreparedForYouItem, 0, preparedRowSize)
for _, item := range items {
if item.ReasonKind == "pick-up" {
pickups = append(pickups, item)
}
}
// A pickup is valuable even when only one genuinely abandoned, unfinished series
// qualifies. Unlike generic recommendations, padding this shelf would make it lie.
appendRowWithMinimum("for-you:pick-up", "Pick these up again", pickups, 1)
topTitle := "Top picks for you"
if minutes > 0 {
topTitle = fmt.Sprintf("Top picks that fit in %d minutes", minutes)
}
appendRow("for-you:picks", topTitle, items)
appendGroupedRows := func(
prefix string,
key func(store.PreparedForYouItem) string,
title func(store.PreparedForYouItem) string,
maxRows int,
filter func(store.PreparedForYouItem) bool,
) {
groups := map[string][]store.PreparedForYouItem{}
order := []string{}
first := map[string]store.PreparedForYouItem{}
for _, item := range items {
if used[item.ItemID] || !filter(item) {
continue
}
groupKey := key(item)
if groupKey == "" {
continue
}
if _, exists := groups[groupKey]; !exists {
order = append(order, groupKey)
first[groupKey] = item
}
groups[groupKey] = append(groups[groupKey], item)
}
added := 0
for _, groupKey := range order {
if added == maxRows {
return
}
candidates := groups[groupKey]
if len(candidates) < minRowItems {
continue
}
if appendRow(prefix+rowKey(groupKey), title(first[groupKey]), candidates) {
added++
}
}
}
appendGroupedRows(
"for-you:because:",
func(item store.PreparedForYouItem) string { return item.ReasonSourceItemID },
func(item store.PreparedForYouItem) string {
return "Because you finished " + item.ReasonSourceTitle
},
2,
func(item store.PreparedForYouItem) bool {
return strings.TrimSpace(item.ReasonSourceTitle) != ""
},
)
appendGroupedRows(
"for-you:genre:",
func(item store.PreparedForYouItem) string {
return strings.ToLower(strings.TrimSpace(item.ReasonGenre))
},
func(item store.PreparedForYouItem) string {
return "More " + item.ReasonGenre + " for you"
},
2,
func(item store.PreparedForYouItem) bool {
return strings.TrimSpace(item.ReasonGenre) != ""
},
)
compatible := make([]store.PreparedForYouItem, 0, len(items))
for _, item := range items {
if !used[item.ItemID] && item.CompatibilityScore > 0.2 {
compatible = append(compatible, item)
}
}
appendRow("for-you:tv-ready", "Plays well on this TV", compatible)
return rows
}
func rowKey(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
var b strings.Builder
for _, r := range value {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
if b.Len() == 0 {
sum := sha256.Sum256([]byte(value))
return fmt.Sprintf("%x", sum[:6])
}
return b.String()
}
func (s *Service) Schedule(
ctx context.Context,
importEvery, fullEvery, refreshEvery time.Duration,
) {
if importEvery <= 0 {
s.log.Info("Tracearr auto-import disabled")
return
}
importTicker := time.NewTicker(importEvery)
defer importTicker.Stop()
var fullC, refreshC <-chan time.Time
var fullTicker, refreshTicker *time.Ticker
if fullEvery > 0 {
fullTicker = time.NewTicker(fullEvery)
fullC = fullTicker.C
defer fullTicker.Stop()
}
if refreshEvery > 0 {
refreshTicker = time.NewTicker(refreshEvery)
refreshC = refreshTicker.C
defer refreshTicker.Stop()
}
for {
select {
case <-ctx.Done():
return
case <-importTicker.C:
if _, err := s.Import(ctx, false); err != nil {
s.log.Warn("scheduled Tracearr import failed", "error", err)
} else {
_ = s.RebuildAll(ctx, false)
}
case <-fullC:
if _, err := s.Import(ctx, true); err != nil {
s.log.Warn("scheduled full Tracearr import failed", "error", err)
} else {
_ = s.RebuildAll(ctx, false)
}
case <-refreshC:
_ = s.RebuildAll(ctx, false)
}
}
}
func (s *Service) beginBuild(userID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.building[userID] {
return false
}
s.building[userID] = true
return true
}
func (s *Service) endBuild(userID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.building, userID)
}
func importedSession(session tracearr.Session, configuredServerID string) (store.TracearrSession, bool) {
if strings.TrimSpace(session.ID) == "" {
return store.TracearrSession{}, false
}
serverID := strings.TrimSpace(session.ServerID)
if serverID == "" {
serverID = strings.TrimSpace(configuredServerID)
}
if serverID == "" {
serverID = "default"
}
startedAt := parsedTime(session.StartedAt)
stoppedAt := parsedTime(session.StoppedAt)
fingerprintRaw, _ := json.Marshal(session)
fingerprint := sha256.Sum256(fingerprintRaw)
return store.TracearrSession{
ServerID: serverID, SessionID: session.ID, UserID: session.User.ID,
Username: session.User.Username, State: session.State,
MediaType: session.MediaType, MediaTitle: session.MediaTitle,
ShowTitle: session.ShowTitle, SeasonNumber: session.SeasonNumber,
EpisodeNumber: session.EpisodeNumber, ProductionYear: session.Year,
StartedAt: startedAt, StoppedAt: stoppedAt,
DurationMs: int64(session.DurationMs), ProgressMs: int64(session.ProgressMs),
TotalDurationMs: int64(session.TotalDurationMs), Watched: session.Watched,
Device: session.Device, Player: session.Player, Product: session.Product,
Platform: session.Platform, IsTranscode: session.IsTranscode,
VideoDecision: session.VideoDecision, AudioDecision: session.AudioDecision,
SourceVideoCodec: session.SourceVideoCodec, SourceAudioCodec: session.SourceAudioCodec,
SourceFingerprint: fingerprint[:],
}, true
}
func tracearrSession(session store.TracearrSession) tracearr.Session {
value := tracearr.Session{
ID: session.SessionID, ServerID: session.ServerID, State: session.State,
MediaType: session.MediaType, MediaTitle: session.MediaTitle,
ShowTitle: session.ShowTitle, SeasonNumber: session.SeasonNumber,
EpisodeNumber: session.EpisodeNumber, Year: session.ProductionYear,
DurationMs: tracearr.FlexibleInt64(session.DurationMs),
ProgressMs: tracearr.FlexibleInt64(session.ProgressMs),
TotalDurationMs: tracearr.FlexibleInt64(session.TotalDurationMs),
Watched: session.Watched, Device: session.Device, Player: session.Player,
Product: session.Product, Platform: session.Platform,
IsTranscode: session.IsTranscode, VideoDecision: session.VideoDecision,
AudioDecision: session.AudioDecision, SourceVideoCodec: session.SourceVideoCodec,
SourceAudioCodec: session.SourceAudioCodec,
}
if session.StartedAt != nil {
value.StartedAt = session.StartedAt.UTC().Format(time.RFC3339Nano)
}
if session.StoppedAt != nil {
value.StoppedAt = session.StoppedAt.UTC().Format(time.RFC3339Nano)
}
value.User.ID = session.UserID
value.User.Username = session.Username
return value
}
func storedResult(
userID string,
builtAt time.Time,
result recommend.PreparedResult,
) (store.RecommendationProfile, []store.ForYouCandidate, error) {
genre, err := json.Marshal(result.Profile.GenreAffinity)
if err != nil {
return store.RecommendationProfile{}, nil, err
}
title, err := json.Marshal(result.Profile.TitleAffinity)
if err != nil {
return store.RecommendationProfile{}, nil, err
}
studio, err := json.Marshal(result.Profile.StudioAffinity)
if err != nil {
return store.RecommendationProfile{}, nil, err
}
codecs, err := json.Marshal(result.Profile.CodecOutcomes)
if err != nil {
return store.RecommendationProfile{}, nil, err
}
profile := store.RecommendationProfile{
EmbyUserID: userID, TracearrUserID: result.Profile.TracearrUserID,
TracearrUsername: result.Profile.TracearrUsername,
SourceSessionCount: result.Profile.SourceSessionCount,
MeanCompletionRatio: result.Profile.MeanCompletionRatio,
TypicalSessionMinutes: result.Profile.TypicalSessionMinutes,
GenreAffinity: genre, TitleAffinity: title, StudioAffinity: studio,
CodecOutcomes: codecs, SignalsThrough: result.Profile.SignalsThrough, BuiltAt: builtAt,
}
candidates := make([]store.ForYouCandidate, 0, len(result.Candidates))
for _, value := range result.Candidates {
candidates = append(candidates, store.ForYouCandidate{
ItemID: value.ItemID, BaseRank: value.BaseRank, BaseScore: value.BaseScore,
RuntimeMinutes: value.RuntimeMinutes, AffinityScore: value.AffinityScore,
CompatibilityScore: value.CompatibilityScore,
CompatibilityLabel: value.CompatibilityLabel, ReasonKind: value.ReasonKind,
ReasonGenre: value.ReasonGenre,
ReasonSourceSessionID: value.ReasonSourceSessionID,
ReasonSourceItemID: value.ReasonSourceItemID,
ReasonSourceTitle: value.ReasonSourceTitle,
RecommendationReason: value.RecommendationReason,
})
}
return profile, candidates, nil
}
func parsedTime(value string) *time.Time {
parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value))
if err != nil {
return nil
}
parsed = parsed.UTC()
return &parsed
}
func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
}
}
+112
View File
@@ -0,0 +1,112 @@
package foryou
import (
"bytes"
"encoding/json"
"testing"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
func TestImportedSessionUsesStableKeyAndRecommendationFingerprint(t *testing.T) {
session := tracearr.Session{
ID: "session-1", MediaType: "movie", MediaTitle: "Arrival",
ProgressMs: 500, TotalDurationMs: 1000,
}
session.User.ID = "user-1"
session.User.Username = "Matt"
first, ok := importedSession(session, "configured-server")
if !ok {
t.Fatal("session was rejected")
}
if first.ServerID != "configured-server" || first.SessionID != "session-1" {
t.Fatalf("key = %q/%q", first.ServerID, first.SessionID)
}
second, _ := importedSession(session, "configured-server")
if !bytes.Equal(first.SourceFingerprint, second.SourceFingerprint) {
t.Fatal("unchanged session did not produce a stable fingerprint")
}
session.ProgressMs = 750
updated, _ := importedSession(session, "configured-server")
if bytes.Equal(first.SourceFingerprint, updated.SourceFingerprint) {
t.Fatal("updated progress was not detected by the fingerprint")
}
}
func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T) {
items := make([]store.PreparedForYouItem, 0, 120)
for i := 0; i < 120; i++ {
sourceID, sourceTitle := "six-feet-under", "Six Feet Under"
genre := "Drama"
if i%2 == 1 {
sourceID, sourceTitle = "arrival", "Arrival"
genre = "Science Fiction"
}
id := "item-" + itoaForTest(i)
items = append(items, store.PreparedForYouItem{
ItemID: id, BaseRank: i + 1,
Payload: json.RawMessage(`{"Id":"` + id + `"}`),
CompatibilityScore: 0.8,
CompatibilityLabel: "Direct plays well on this TV",
RecommendationReason: "Because you finished " + sourceTitle,
ReasonGenre: genre, ReasonSourceItemID: sourceID,
ReasonSourceTitle: sourceTitle,
})
}
rows := buildPreparedRows(items, 0, 4)
if len(rows) < 4 {
t.Fatalf("expected several prepared rows, got %d", len(rows))
}
titles := map[string]bool{}
itemIDs := map[string]bool{}
for _, row := range rows {
titles[row.Title] = true
for _, raw := range row.Items {
var item struct {
ID string `json:"Id"`
}
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatal(err)
}
if itemIDs[item.ID] {
t.Fatalf("item %q appeared in more than one row", item.ID)
}
itemIDs[item.ID] = true
}
}
if !titles["Because you finished Six Feet Under"] ||
!titles["Because you finished Arrival"] {
t.Fatalf("completed-title rows = %+v", titles)
}
}
func TestBuildPreparedRowsKeepsASingleGenuinePickup(t *testing.T) {
rows := buildPreparedRows([]store.PreparedForYouItem{{
ItemID: "show-1", BaseRank: 1,
Payload: json.RawMessage(`{"Id":"show-1"}`),
ReasonKind: "pick-up",
RecommendationReason: "You left this in season 1 · pick it up again",
}}, 0, 4)
if len(rows) != 1 || rows[0].ID != "for-you:pick-up" ||
rows[0].Title != "Pick these up again" || len(rows[0].Items) != 1 {
t.Fatalf("pickup rows = %+v", rows)
}
}
func itoaForTest(value int) string {
if value == 0 {
return "0"
}
var digits [20]byte
position := len(digits)
for value > 0 {
position--
digits[position] = byte('0' + value%10)
value /= 10
}
return string(digits[position:])
}
+204
View File
@@ -0,0 +1,204 @@
// Package logging configures the server's human-readable, structured log output.
package logging
import (
"context"
"io"
"log/slog"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// ParseLevel returns a supported slog level, defaulting to INFO for empty or invalid
// values. Keeping this forgiving prevents a typo in Docker configuration from stopping
// the gateway.
func ParseLevel(value string) slog.Level {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "DEBUG":
return slog.LevelDebug
case "WARN", "WARNING":
return slog.LevelWarn
case "ERROR":
return slog.LevelError
default:
return slog.LevelInfo
}
}
// New returns a text logger suited to `docker compose logs`. Fields remain structured
// key=value pairs, but each event is one compact line rather than a JSON object.
func New(w io.Writer, level slog.Leveler) *slog.Logger {
logger, _ := NewBuffered(w, level, 0)
return logger
}
// Event is the browser-safe representation of one structured server log record.
type Event struct {
Sequence int64 `json:"sequence"`
OccurredAt time.Time `json:"occurredAt"`
Level string `json:"level"`
Message string `json:"message"`
Attributes map[string]string `json:"attributes,omitempty"`
}
// EventPage is cursor based so an admin browser can drain bursts without repeatedly
// downloading records it has already rendered.
type EventPage struct {
Events []Event `json:"events"`
Next int64 `json:"next"`
Oldest int64 `json:"oldest"`
Latest int64 `json:"latest"`
Dropped int64 `json:"dropped"`
HasMore bool `json:"hasMore"`
}
// Buffer is a bounded, concurrency-safe ring of recent structured log records.
// Bounding is important: a broken TV can generate traffic indefinitely, while 20,000
// records is still enough context for an operator to inspect a sustained incident.
type Buffer struct {
mu sync.RWMutex
capacity int
events []Event
next atomic.Int64
}
// NewBuffered writes normal text logs and mirrors accepted records into a ring buffer.
func NewBuffered(w io.Writer, level slog.Leveler, capacity int) (*slog.Logger, *Buffer) {
options := &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.TimeKey {
return slog.String(slog.TimeKey, attr.Value.Time().UTC().Format(time.RFC3339))
}
return attr
},
}
buffer := &Buffer{capacity: capacity}
text := slog.NewTextHandler(w, options)
if capacity <= 0 {
return slog.New(text), buffer
}
return slog.New(&captureHandler{next: text, buffer: buffer, level: level}), buffer
}
type captureHandler struct {
next slog.Handler
buffer *Buffer
level slog.Leveler
attrs []slog.Attr
groups []string
}
func (h *captureHandler) Enabled(ctx context.Context, level slog.Level) bool {
return level >= h.level.Level() && h.next.Enabled(ctx, level)
}
func (h *captureHandler) Handle(ctx context.Context, record slog.Record) error {
attributes := make(map[string]string, record.NumAttrs()+len(h.attrs))
for _, attr := range h.attrs {
addAttribute(attributes, h.groups, attr)
}
record.Attrs(func(attr slog.Attr) bool {
addAttribute(attributes, h.groups, attr)
return true
})
h.buffer.append(Event{
OccurredAt: record.Time.UTC(),
Level: record.Level.String(),
Message: record.Message,
Attributes: attributes,
})
return h.next.Handle(ctx, record)
}
func (h *captureHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
clone := *h
clone.next = h.next.WithAttrs(attrs)
clone.attrs = append(append([]slog.Attr{}, h.attrs...), attrs...)
return &clone
}
func (h *captureHandler) WithGroup(name string) slog.Handler {
clone := *h
clone.next = h.next.WithGroup(name)
clone.groups = append(append([]string{}, h.groups...), name)
return &clone
}
func addAttribute(target map[string]string, groups []string, attr slog.Attr) {
attr.Value = attr.Value.Resolve()
if attr.Equal(slog.Attr{}) {
return
}
key := strings.Join(append(append([]string{}, groups...), attr.Key), ".")
if attr.Value.Kind() == slog.KindGroup {
for _, child := range attr.Value.Group() {
addAttribute(target, append(groups, attr.Key), child)
}
return
}
switch attr.Value.Kind() {
case slog.KindDuration:
target[key] = attr.Value.Duration().String()
case slog.KindTime:
target[key] = attr.Value.Time().UTC().Format(time.RFC3339Nano)
default:
target[key] = attr.Value.String()
if attr.Value.Kind() == slog.KindInt64 {
target[key] = strconv.FormatInt(attr.Value.Int64(), 10)
}
}
}
func (b *Buffer) append(event Event) {
event.Sequence = b.next.Add(1)
b.mu.Lock()
defer b.mu.Unlock()
if len(b.events) == b.capacity {
copy(b.events, b.events[1:])
b.events[len(b.events)-1] = event
return
}
b.events = append(b.events, event)
}
// Events returns records strictly newer than after, up to limit. If the caller fell
// behind the ring, Dropped reports the gap and delivery resumes at the oldest record.
func (b *Buffer) Events(after int64, limit int) EventPage {
if limit < 1 {
limit = 250
}
if limit > 1000 {
limit = 1000
}
b.mu.RLock()
defer b.mu.RUnlock()
latest := b.next.Load()
page := EventPage{Next: after, Latest: latest, Events: []Event{}}
if len(b.events) == 0 {
return page
}
page.Oldest = b.events[0].Sequence
if after < page.Oldest-1 {
page.Dropped = page.Oldest - after - 1
after = page.Oldest - 1
}
start := len(b.events)
for i := range b.events {
if b.events[i].Sequence > after {
start = i
break
}
}
end := min(start+limit, len(b.events))
page.Events = append(page.Events, b.events[start:end]...)
if len(page.Events) > 0 {
page.Next = page.Events[len(page.Events)-1].Sequence
}
page.HasMore = page.Next < latest
return page
}
+58
View File
@@ -0,0 +1,58 @@
package logging
import (
"bytes"
"log/slog"
"strings"
"testing"
)
func TestNewWritesReadableStructuredText(t *testing.T) {
var output bytes.Buffer
New(&output, slog.LevelInfo).Info("server ready", "listen", ":8080")
line := output.String()
if strings.HasPrefix(strings.TrimSpace(line), "{") {
t.Fatalf("expected text output, got JSON: %s", line)
}
for _, want := range []string{`level=INFO`, `msg="server ready"`, `listen=:8080`} {
if !strings.Contains(line, want) {
t.Errorf("output %q does not contain %q", line, want)
}
}
}
func TestBufferedLoggerRetainsStructuredEventsWithCursorPagination(t *testing.T) {
var output bytes.Buffer
logger, buffer := NewBuffered(&output, slog.LevelDebug, 3)
for i := 1; i <= 5; i++ {
logger.Info("request complete", "number", i)
}
first := buffer.Events(0, 2)
if first.Dropped != 2 || len(first.Events) != 2 || !first.HasMore {
t.Fatalf("unexpected first page: %+v", first)
}
if first.Events[0].Sequence != 3 || first.Events[0].Attributes["number"] != "3" {
t.Fatalf("oldest retained event was not delivered: %+v", first.Events[0])
}
second := buffer.Events(first.Next, 2)
if len(second.Events) != 1 || second.Events[0].Sequence != 5 || second.HasMore {
t.Fatalf("unexpected second page: %+v", second)
}
}
func TestParseLevel(t *testing.T) {
tests := map[string]slog.Level{
"": slog.LevelInfo,
"debug": slog.LevelDebug,
"WARNING": slog.LevelWarn,
"error": slog.LevelError,
"unknown": slog.LevelInfo,
}
for input, want := range tests {
if got := ParseLevel(input); got != want {
t.Errorf("ParseLevel(%q) = %v, want %v", input, got, want)
}
}
}
+695
View File
@@ -0,0 +1,695 @@
package recommend
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
type PreparedLibrarySource interface {
AllRecommendationCandidates(ctx context.Context) ([]json.RawMessage, error)
}
type PreparedEvidence struct {
SessionID string `json:"sessionId"`
ItemID string `json:"itemId"`
Title string `json:"title"`
Genres []string `json:"genres,omitempty"`
}
type PreparedTitleAffinity struct {
Weight float64 `json:"weight"`
Title string `json:"title"`
SessionID string `json:"sessionId,omitempty"`
}
type PreparedSessionMapping struct {
ServerID string
SessionID string
ItemID string
SeriesID string
}
type PreparedProfile struct {
TracearrUserID string
TracearrUsername string
SourceSessionCount int
MeanCompletionRatio float64
TypicalSessionMinutes int
GenreAffinity map[string]float64
TitleAffinity map[string]PreparedTitleAffinity
StudioAffinity map[string]float64
CodecOutcomes map[string]map[string]int
SignalsThrough *time.Time
}
type PreparedCandidate struct {
ItemID string
BaseRank int
BaseScore float64
RuntimeMinutes int
AffinityScore float64
CompatibilityScore float64
CompatibilityLabel string
ReasonKind string
ReasonGenre string
ReasonSourceSessionID string
ReasonSourceItemID string
ReasonSourceTitle string
RecommendationReason string
}
type PreparedResult struct {
Profile PreparedProfile
Candidates []PreparedCandidate
Mappings []PreparedSessionMapping
}
var ErrPreparedLibraryUnavailable = errors.New("recommend: prepared library unavailable")
// PrepareForYou performs the expensive work outside a television request. It consumes
// locally imported Tracearr sessions and the complete imported catalogue, producing a
// compact profile and an intentionally over-provisioned ranked pool.
func (e *Engine) PrepareForYou(
ctx context.Context,
cred emby.Credentials,
username string,
sessions []tracearr.Session,
) (PreparedResult, error) {
sessions = recommendationSessions(sessions)
history, favorites, err := e.gatherSignals(ctx, cred)
if err != nil {
return PreparedResult{}, err
}
profile := BuildProfile(history, favorites)
library, ok := e.Library.(PreparedLibrarySource)
if !ok {
return PreparedResult{}, ErrPreparedLibraryUnavailable
}
raws, err := library.AllRecommendationCandidates(ctx)
if err != nil {
return PreparedResult{}, err
}
catalogue := Decode(raws)
if len(catalogue) == 0 {
return PreparedResult{}, ErrPreparedLibraryUnavailable
}
browsed := map[string]bool{}
if e.Behavior != nil {
browsedRaws, browseErr := e.Behavior.BrowsingCandidates(
ctx, cred.UserID, time.Now().Add(-30*24*time.Hour), 30,
)
if browseErr != nil {
e.log.Warn("browsing signals unavailable during For You rebuild", "error", browseErr)
} else {
for i, item := range Decode(browsedRaws) {
profile.absorbTaste(item, 0.55*powDecay(0.92, i))
browsed[item.ID] = true
}
}
}
index := newCatalogueIndex(catalogue)
evidenceByGenre := map[string][]PreparedEvidence{}
evidenceSeen := map[string]bool{}
addCompletedEvidence := func(item Item, sessionID string) {
itemID := item.ID
title := item.Name
if strings.EqualFold(item.Type, "Episode") &&
strings.TrimSpace(item.SeriesName) != "" {
title = item.SeriesName
if item.SeriesID != "" {
itemID = item.SeriesID
}
}
for _, genre := range item.Genres {
genreKey := strings.ToLower(strings.TrimSpace(genre))
evidenceKey := genreKey + "|" + itemID
if genreKey == "" || itemID == "" || evidenceSeen[evidenceKey] {
continue
}
evidenceSeen[evidenceKey] = true
evidenceByGenre[genreKey] = append(
evidenceByGenre[genreKey],
PreparedEvidence{
SessionID: sessionID,
ItemID: itemID,
Title: title,
Genres: append([]string(nil), item.Genres...),
},
)
}
}
// Emby's played history is already a trustworthy completion signal and gives the
// explanation pool breadth even when Tracearr title matching is sparse.
for _, item := range history {
if item.UserData.Played {
addCompletedEvidence(item, "")
}
}
titleAffinity := map[string]PreparedTitleAffinity{}
mappings := make([]PreparedSessionMapping, 0, len(sessions))
var completionTotal float64
durations := make([]int, 0, len(sessions))
var signalsThrough *time.Time
tracearrUserID := ""
tracearrUsername := strings.TrimSpace(username)
titleSignalCount := map[string]int{}
for i, session := range sessions {
completion := session.Completion()
completionTotal += completion
if minutes := int(int64(session.DurationMs) / 60_000); minutes > 0 {
durations = append(durations, minutes)
}
if started, ok := parseTracearrTime(session.StartedAt); ok &&
(signalsThrough == nil || started.After(*signalsThrough)) {
value := started
signalsThrough = &value
}
if tracearrUserID == "" {
tracearrUserID = strings.TrimSpace(session.User.ID)
}
if tracearrUsername == "" {
tracearrUsername = strings.TrimSpace(session.User.Username)
}
if completion > 0 {
profile.SeenTitles[tracearrSeenKey(session)] = true
}
item, matched := index.match(session)
if !matched {
continue
}
seriesID := item.SeriesID
if strings.EqualFold(session.MediaType, "episode") &&
strings.EqualFold(item.Type, "Series") {
seriesID = item.ID
}
mappings = append(mappings, PreparedSessionMapping{
ServerID: session.ServerID, SessionID: session.ID,
ItemID: item.ID, SeriesID: seriesID,
})
// Episode-heavy programmes should be strong signals, but not dozens of
// independent votes. Each repeat contributes less than the previous one.
repeats := titleSignalCount[item.ID]
titleSignalCount[item.ID] = repeats + 1
weight := (0.2 + completion) * math.Pow(0.985, float64(i)) *
math.Pow(0.65, float64(repeats))
profile.absorbTaste(item, weight)
if completion > 0 {
if item.ID != "" {
profile.Seen[item.ID] = true
}
if item.SeriesID != "" {
profile.Seen[item.SeriesID] = true
}
}
current := titleAffinity[item.ID]
current.Weight += weight
current.Title = item.Name
if current.SessionID == "" {
current.SessionID = session.ID
}
titleAffinity[item.ID] = current
if completion >= 0.9 {
addCompletedEvidence(item, session.ID)
}
}
compatibility := buildCompatibilityProfile(sessions)
type scored struct {
item Item
base float64
compatibility float64
score float64
}
ranked := make([]scored, 0, len(catalogue))
seenCandidates := map[string]bool{}
for _, candidate := range catalogue {
if candidate.ID == "" || seenCandidates[candidate.ID] {
continue
}
seenCandidates[candidate.ID] = true
base := profile.Score(candidate)
if base < 0 {
continue
}
compatibilityValue := compatibilityScore(candidate, compatibility)
ranked = append(ranked, scored{
item: candidate, base: base, compatibility: compatibilityValue,
score: base + compatibilityValue*1.4,
})
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].score != ranked[j].score {
return ranked[i].score > ranked[j].score
}
return ranked[i].item.Name < ranked[j].item.Name
})
prepared := make([]PreparedCandidate, 0, len(ranked))
completedReasonCounts := map[string]int{}
for rank, entry := range ranked {
reason, label, kind, genre, evidence := explainPreparedRecommendation(
profile, entry.item, compatibility, browsed[entry.item.ID], evidenceByGenre,
completedReasonCounts,
)
prepared = append(prepared, PreparedCandidate{
ItemID: entry.item.ID,
BaseRank: rank + 1,
BaseScore: entry.score,
RuntimeMinutes: entry.item.RuntimeMinutes(),
AffinityScore: entry.base,
CompatibilityScore: entry.compatibility,
CompatibilityLabel: label,
ReasonKind: kind,
ReasonGenre: genre,
ReasonSourceSessionID: evidence.SessionID,
ReasonSourceItemID: evidence.ItemID,
ReasonSourceTitle: evidence.Title,
RecommendationReason: reason,
})
}
pickups := e.prepareAbandonedShows(ctx, cred, index, sessions, compatibility, time.Now())
if len(pickups) > 0 {
for i := range prepared {
prepared[i].BaseRank += len(pickups)
}
for i := range pickups {
pickups[i].BaseRank = i + 1
}
prepared = append(pickups, prepared...)
}
meanCompletion := 0.0
if len(sessions) > 0 {
meanCompletion = completionTotal / float64(len(sessions))
}
codecs := map[string]map[string]int{
"direct": compatibility.directCodecs,
"transcode": compatibility.transcodeCodecs,
}
return PreparedResult{
Profile: PreparedProfile{
TracearrUserID: tracearrUserID, TracearrUsername: tracearrUsername,
SourceSessionCount: len(sessions), MeanCompletionRatio: meanCompletion,
TypicalSessionMinutes: medianInt(durations),
GenreAffinity: profile.GenreWeights, TitleAffinity: titleAffinity,
StudioAffinity: profile.StudioWeights, CodecOutcomes: codecs,
SignalsThrough: signalsThrough,
},
Candidates: prepared,
Mappings: mappings,
}, nil
}
const (
abandonedShowAge = 21 * 24 * time.Hour
maxPickupShows = 20
)
type abandonedShowProgress struct {
item Item
lastActivity time.Time
lastSeason int
completedEpisodes map[string]bool
}
// prepareAbandonedShows adds watched series back into the otherwise-unwatched candidate
// pool. Emby Next Up is the completion boundary: if Emby has no next episode for this
// user, the show is complete and cannot appear here.
func (e *Engine) prepareAbandonedShows(
ctx context.Context,
cred emby.Credentials,
index catalogueIndex,
sessions []tracearr.Session,
compatibility compatibilityProfile,
now time.Time,
) []PreparedCandidate {
nextUpSource, ok := e.source.(NextUpSource)
if !ok {
return nil
}
result, err := nextUpSource.NextUp(ctx, cred, url.Values{
"Limit": {"5000"},
"Fields": {"SeriesName,SeriesId,ParentIndexNumber,IndexNumber,RunTimeTicks"},
"EnableImages": {"false"},
"EnableUserData": {"true"},
"EnableTotalRecordCount": {"false"},
})
if err != nil {
e.log.Warn("could not check abandoned shows against Emby Next Up", "error", err)
return nil
}
return abandonedShowCandidates(index, sessions, Decode(result.Items), compatibility, now)
}
func abandonedShowCandidates(
index catalogueIndex,
sessions []tracearr.Session,
nextUp []Item,
compatibility compatibilityProfile,
now time.Time,
) []PreparedCandidate {
progress := map[string]*abandonedShowProgress{}
for _, session := range sessions {
if !strings.EqualFold(session.MediaType, "episode") ||
strings.TrimSpace(session.ShowTitle) == "" ||
session.Completion() < 0.1 {
continue
}
series, matched := index.match(session)
if !matched || series.ID == "" || !strings.EqualFold(series.Type, "Series") {
continue
}
activity, ok := tracearrActivityTime(session)
if !ok {
continue
}
current := progress[series.ID]
if current == nil {
current = &abandonedShowProgress{
item: series, completedEpisodes: map[string]bool{},
}
progress[series.ID] = current
}
if activity.After(current.lastActivity) {
current.lastActivity = activity
if session.SeasonNumber != nil {
current.lastSeason = *session.SeasonNumber
}
}
if session.Completion() >= 0.9 && session.SeasonNumber != nil &&
session.EpisodeNumber != nil {
key := strconv.Itoa(*session.SeasonNumber) + ":" + strconv.Itoa(*session.EpisodeNumber)
current.completedEpisodes[key] = true
}
}
nextBySeries := map[string]Item{}
for _, episode := range nextUp {
// Specials do not mean the main programme is unfinished.
if episode.SeriesID == "" || episode.ParentIndexNumber <= 0 {
continue
}
if _, exists := nextBySeries[episode.SeriesID]; !exists {
nextBySeries[episode.SeriesID] = episode
}
}
type pickup struct {
progress *abandonedShowProgress
next Item
}
eligible := make([]pickup, 0, len(progress))
cutoff := now.Add(-abandonedShowAge)
for seriesID, watched := range progress {
next, unfinished := nextBySeries[seriesID]
if !unfinished || watched.lastActivity.After(cutoff) {
continue
}
eligible = append(eligible, pickup{progress: watched, next: next})
}
sort.SliceStable(eligible, func(i, j int) bool {
iLaterSeason := eligible[i].progress.lastSeason > 1
jLaterSeason := eligible[j].progress.lastSeason > 1
if iLaterSeason != jLaterSeason {
return iLaterSeason
}
if !eligible[i].progress.lastActivity.Equal(eligible[j].progress.lastActivity) {
return eligible[i].progress.lastActivity.After(eligible[j].progress.lastActivity)
}
return len(eligible[i].progress.completedEpisodes) >
len(eligible[j].progress.completedEpisodes)
})
if len(eligible) > maxPickupShows {
eligible = eligible[:maxPickupShows]
}
out := make([]PreparedCandidate, 0, len(eligible))
for _, candidate := range eligible {
watched := candidate.progress
next := candidate.next
reason := abandonedShowReason(watched.lastSeason, next.ParentIndexNumber)
compatibilityValue := compatibilityScore(watched.item, compatibility)
out = append(out, PreparedCandidate{
ItemID: watched.item.ID,
RuntimeMinutes: next.RuntimeMinutes(),
BaseScore: float64(len(watched.completedEpisodes)),
AffinityScore: float64(len(watched.completedEpisodes)),
CompatibilityScore: compatibilityValue,
CompatibilityLabel: compatibilityLabelForScore(compatibilityValue),
ReasonKind: "pick-up",
ReasonSourceItemID: next.ID,
ReasonSourceTitle: next.Name,
RecommendationReason: reason,
})
}
return out
}
func tracearrActivityTime(session tracearr.Session) (time.Time, bool) {
if stopped, ok := parseTracearrTime(session.StoppedAt); ok {
return stopped, true
}
return parseTracearrTime(session.StartedAt)
}
func abandonedShowReason(lastSeason, nextSeason int) string {
switch {
case lastSeason == 1 && nextSeason > 1:
return fmt.Sprintf("You finished season 1 · season %d is waiting", nextSeason)
case lastSeason > 1 && nextSeason > lastSeason:
return fmt.Sprintf("You made it through season %d · season %d is waiting", lastSeason, nextSeason)
case lastSeason > 1:
return fmt.Sprintf("You made it to season %d · pick it up again", lastSeason)
case lastSeason == 1:
return "You left this in season 1 · pick it up again"
default:
return "You left this unfinished · pick it up again"
}
}
func compatibilityLabelForScore(score float64) string {
switch {
case score > 0.2:
return "Direct plays well on this TV"
case score < -0.2:
return "May need transcoding on this TV"
default:
return "TV compatibility not yet learned"
}
}
func explainPreparedRecommendation(
profile Profile,
item Item,
compatibility compatibilityProfile,
browsed bool,
evidenceByGenre map[string][]PreparedEvidence,
completedReasonCounts map[string]int,
) (reason, label, kind, genre string, evidence PreparedEvidence) {
for _, wanted := range profile.TopGenres(5) {
for _, candidateGenre := range item.Genres {
if strings.EqualFold(wanted, candidateGenre) {
genre = candidateGenre
options := evidenceByGenre[strings.ToLower(strings.TrimSpace(wanted))]
strong := make([]PreparedEvidence, 0, len(options))
for _, option := range options {
if strongEvidenceMatch(item, option) {
strong = append(strong, option)
}
}
if len(strong) > 0 {
evidence = strong[stableEvidenceIndex(item.ID, len(strong))]
}
break
}
}
if genre != "" {
break
}
}
// Keep specific evidence prominent without letting it monopolise a row. One third
// of otherwise eligible cards deliberately uses the broader genre explanation,
// and no completed title can explain more than four candidates in a prepared pool.
useCompleted := evidence.Title != "" &&
stableEvidenceIndex("reason-kind:"+item.ID, 3) != 0 &&
completedReasonCounts[evidence.ItemID] < 4
switch {
case browsed:
reason, kind = "You explored this recently", "browsed"
evidence = PreparedEvidence{}
case useCompleted:
reason, kind = "Because you finished "+evidence.Title, "completed-title"
completedReasonCounts[evidence.ItemID]++
case genre != "":
reason, kind = "Matches your "+genre+" viewing", "genre"
evidence = PreparedEvidence{}
case len(profile.Seeds) > 0:
reason, kind = "Inspired by "+profile.Seeds[0].Name, "recent-title"
evidence = PreparedEvidence{}
default:
reason, kind = "Matches your recent viewing", "generic"
evidence = PreparedEvidence{}
}
switch score := compatibilityScore(item, compatibility); {
case score > 0.2:
label = "Direct plays well on this TV"
reason += " · " + label
case score < -0.2:
label = "May need transcoding on this TV"
default:
label = "TV compatibility not yet learned"
}
return reason, label, kind, genre, evidence
}
func strongEvidenceMatch(item Item, evidence PreparedEvidence) bool {
shared := 0
broadOnly := true
for _, candidateGenre := range item.Genres {
for _, evidenceGenre := range evidence.Genres {
if !strings.EqualFold(strings.TrimSpace(candidateGenre), strings.TrimSpace(evidenceGenre)) {
continue
}
shared++
switch strings.ToLower(strings.TrimSpace(candidateGenre)) {
case "action", "adventure", "comedy", "drama", "thriller":
default:
broadOnly = false
}
break
}
}
return shared >= 2 || shared == 1 && !broadOnly
}
func stableEvidenceIndex(itemID string, size int) int {
if size <= 1 {
return 0
}
var hash uint32 = 2166136261
for _, value := range []byte(itemID) {
hash ^= uint32(value)
hash *= 16777619
}
return int(hash % uint32(size))
}
type catalogueIndex struct {
movieExact map[string]Item
movieLoose map[string]Item
series map[string]Item
ambiguous map[string]bool
}
func newCatalogueIndex(items []Item) catalogueIndex {
index := catalogueIndex{
movieExact: map[string]Item{}, movieLoose: map[string]Item{},
series: map[string]Item{}, ambiguous: map[string]bool{},
}
for _, item := range items {
key := normalizePreparedTitle(item.Name)
switch item.Type {
case "Movie":
if item.ProductionYear > 0 {
index.movieExact[key+"|"+itoa(item.ProductionYear)] = item
}
if _, exists := index.movieLoose[key]; exists {
index.ambiguous["movie|"+key] = true
} else {
index.movieLoose[key] = item
}
case "Series":
if _, exists := index.series[key]; exists {
index.ambiguous["series|"+key] = true
} else {
index.series[key] = item
}
}
}
return index
}
func (i catalogueIndex) match(session tracearr.Session) (Item, bool) {
if strings.EqualFold(session.MediaType, "episode") && strings.TrimSpace(session.ShowTitle) != "" {
key := normalizePreparedTitle(session.ShowTitle)
if i.ambiguous["series|"+key] {
return Item{}, false
}
item, ok := i.series[key]
return item, ok
}
key := normalizePreparedTitle(session.MediaTitle)
if session.Year != nil && *session.Year > 0 {
if item, ok := i.movieExact[key+"|"+itoa(*session.Year)]; ok {
return item, true
}
}
if i.ambiguous["movie|"+key] {
return Item{}, false
}
item, ok := i.movieLoose[key]
return item, ok
}
func normalizePreparedTitle(value string) string {
var b strings.Builder
for _, r := range strings.ToLower(value) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
func parseTracearrTime(value string) (time.Time, bool) {
parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value))
if err != nil {
return time.Time{}, false
}
return parsed.UTC(), true
}
func medianInt(values []int) int {
if len(values) == 0 {
return 0
}
copyValues := append([]int(nil), values...)
sort.Ints(copyValues)
mid := len(copyValues) / 2
if len(copyValues)%2 == 1 {
return copyValues[mid]
}
return (copyValues[mid-1] + copyValues[mid]) / 2
}
func itoa(value int) string {
if value == 0 {
return "0"
}
var buf [20]byte
pos := len(buf)
for value > 0 {
pos--
buf[pos] = byte('0' + value%10)
value /= 10
}
return string(buf[pos:])
}
+155
View File
@@ -0,0 +1,155 @@
// Package sonarr provides the small read-only slice of Sonarr used by the home screen.
package sonarr
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type Client struct {
baseURL string
apiKey string
http *http.Client
}
type Image struct {
CoverType string `json:"coverType"`
URL string `json:"url"`
RemoteURL string `json:"remoteUrl"`
}
type Series struct {
ID int `json:"id"`
Title string `json:"title"`
Overview string `json:"overview"`
Year int `json:"year"`
Network string `json:"network"`
Genres []string `json:"genres"`
Images []Image `json:"images"`
}
type EpisodeFile struct {
DateAdded *time.Time `json:"dateAdded"`
}
type Episode struct {
ID int `json:"id"`
SeriesID int `json:"seriesId"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
Title string `json:"title"`
Overview string `json:"overview"`
AirDateUTC *time.Time `json:"airDateUtc"`
Runtime int `json:"runtime"`
HasFile bool `json:"hasFile"`
Monitored bool `json:"monitored"`
Grabbed bool `json:"grabbed"`
Series Series `json:"series"`
EpisodeFile *EpisodeFile `json:"episodeFile"`
}
type APIError struct {
StatusCode int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("sonarr: status %d: %s", e.StatusCode, e.Body)
}
func New(baseURL, apiKey string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
// Calendar returns episodes in [start, end), including series artwork and imported-file
// details so Memby can distinguish upcoming, downloading and already-added episodes.
func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Episode, error) {
params := url.Values{
"start": {start.UTC().Format(time.RFC3339Nano)},
"end": {end.UTC().Format(time.RFC3339Nano)},
"unmonitored": {"true"},
"includeSeries": {"true"},
"includeEpisodeFile": {"true"},
"includeEpisodeImages": {"true"},
}
req, err := c.request(ctx, "/api/v3/calendar", params)
if err != nil {
return nil, err
}
var episodes []Episode
if err := c.do(req, &episodes); err != nil {
return nil, err
}
return episodes, nil
}
// MediaCover fetches a series poster or fanart without exposing the Sonarr API key.
func (c *Client) MediaCover(ctx context.Context, seriesID int, coverType string) (*http.Response, error) {
if seriesID <= 0 || (coverType != "poster" && coverType != "fanart") {
return nil, fmt.Errorf("sonarr: invalid media cover")
}
path := "/MediaCover/" + strconv.Itoa(seriesID) + "/" + coverType + ".jpg"
req, err := c.request(ctx, path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "image/*")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("sonarr: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return nil, &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
return resp, nil
}
func (c *Client) request(ctx context.Context, path string, params url.Values) (*http.Request, error) {
endpoint := c.baseURL + path
if len(params) > 0 {
endpoint += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Api-Key", c.apiKey)
req.Header.Set("Accept", "application/json")
return req, nil
}
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("sonarr: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("sonarr: decode response: %w", err)
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package sonarr
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
var gotQuery string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/calendar" {
t.Errorf("path = %q", r.URL.Path)
}
if got := r.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q", got)
}
if r.URL.Query().Get("includeSeries") != "true" ||
r.URL.Query().Get("includeEpisodeFile") != "true" {
t.Errorf("missing include flags: %s", r.URL.RawQuery)
}
gotQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"id":7,"seriesId":2,"title":"Arrival","hasFile":true}]`))
}))
defer upstream.Close()
client := New(upstream.URL, "secret", time.Second)
episodes, err := client.Calendar(
context.Background(),
time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC),
time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC),
)
if err != nil {
t.Fatal(err)
}
if len(episodes) != 1 || episodes[0].ID != 7 || !episodes[0].HasFile {
t.Fatalf("unexpected episodes: %+v", episodes)
}
if gotQuery == "" || rHasAPIKey(gotQuery) {
t.Fatalf("API key leaked into query: %q", gotQuery)
}
}
func rHasAPIKey(query string) bool {
return strings.Contains(query, "secret")
}
+602
View File
@@ -0,0 +1,602 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
const tracearrImportStateKey = "tracearr_import_state"
type TracearrSessionKey struct {
ServerID string
SessionID string
}
type TracearrSession struct {
ServerID string
SessionID string
UserID string
Username string
State string
MediaType string
MediaTitle string
ShowTitle string
SeasonNumber *int
EpisodeNumber *int
ProductionYear *int
StartedAt *time.Time
StoppedAt *time.Time
DurationMs int64
ProgressMs int64
TotalDurationMs int64
Watched bool
Device string
Player string
Product string
Platform string
IsTranscode bool
VideoDecision string
AudioDecision string
SourceVideoCodec string
SourceAudioCodec string
EmbyItemID string
EmbySeriesID string
SourceFingerprint []byte
}
type TracearrImportState struct {
LastIncrementalAt *time.Time `json:"lastIncrementalAt,omitempty"`
LastFullAt *time.Time `json:"lastFullAt,omitempty"`
LastError string `json:"lastError,omitempty"`
}
type RecommendationProfile struct {
EmbyUserID string
TracearrUserID string
TracearrUsername string
SourceSessionCount int
MeanCompletionRatio float64
TypicalSessionMinutes int
GenreAffinity json.RawMessage
TitleAffinity json.RawMessage
StudioAffinity json.RawMessage
CodecOutcomes json.RawMessage
SignalsThrough *time.Time
BuiltAt time.Time
}
type ForYouCandidate struct {
ItemID string
BaseRank int
BaseScore float64
RuntimeMinutes int
AffinityScore float64
CompatibilityScore float64
CompatibilityLabel string
ReasonKind string
ReasonGenre string
ReasonSourceSessionID string
ReasonSourceItemID string
ReasonSourceTitle string
RecommendationReason string
}
type PreparedForYouItem struct {
ItemID string
BaseRank int
Payload json.RawMessage
RuntimeMinutes int
CompatibilityScore float64
CompatibilityLabel string
RecommendationReason string
ReasonKind string
ReasonGenre string
ReasonSourceItemID string
ReasonSourceTitle string
}
type ForYouStats struct {
TracearrSessions int64 `json:"tracearrSessions"`
Profiles int64 `json:"profiles"`
Candidates int64 `json:"candidates"`
LastFullImport *time.Time `json:"lastFullImport,omitempty"`
}
func (s *Store) TracearrSessionCount(ctx context.Context) (int64, error) {
var count int64
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM tracearr_sessions`).Scan(&count); err != nil {
return 0, fmt.Errorf("store: count tracearr sessions: %w", err)
}
return count, nil
}
func (s *Store) TracearrFingerprints(
ctx context.Context,
keys []TracearrSessionKey,
) (map[TracearrSessionKey][]byte, error) {
out := make(map[TracearrSessionKey][]byte, len(keys))
if len(keys) == 0 {
return out, nil
}
servers := make([]string, 0, len(keys))
ids := make([]string, 0, len(keys))
for _, key := range keys {
servers = append(servers, key.ServerID)
ids = append(ids, key.SessionID)
}
rows, err := s.pool.Query(ctx, `
SELECT current.server_id, current.tracearr_session_id, current.source_fingerprint
FROM tracearr_sessions current
JOIN unnest($1::text[], $2::text[]) wanted(server_id, session_id)
ON current.server_id = wanted.server_id
AND current.tracearr_session_id = wanted.session_id`,
servers, ids)
if err != nil {
return nil, fmt.Errorf("store: tracearr fingerprints: %w", err)
}
defer rows.Close()
for rows.Next() {
var key TracearrSessionKey
var fingerprint []byte
if err := rows.Scan(&key.ServerID, &key.SessionID, &fingerprint); err != nil {
return nil, err
}
out[key] = fingerprint
}
return out, rows.Err()
}
func (s *Store) UpsertTracearrSessions(
ctx context.Context,
sessions []TracearrSession,
seenAt time.Time,
) error {
if len(sessions) == 0 {
return nil
}
batch := &pgx.Batch{}
for _, session := range sessions {
batch.Queue(`
INSERT INTO tracearr_sessions (
server_id, tracearr_session_id, tracearr_user_id, username, state,
media_type, media_title, show_title, season_number, episode_number,
production_year, started_at, stopped_at, duration_ms, progress_ms,
total_duration_ms, watched, device, player, product, platform,
is_transcode, video_decision, audio_decision, source_video_codec,
source_audio_codec, emby_item_id, emby_series_id, source_fingerprint,
source_seen_at, imported_at, updated_at
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,
$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,now(),now()
)
ON CONFLICT (server_id, tracearr_session_id) DO UPDATE SET
tracearr_user_id = EXCLUDED.tracearr_user_id,
username = EXCLUDED.username,
state = EXCLUDED.state,
media_type = EXCLUDED.media_type,
media_title = EXCLUDED.media_title,
show_title = EXCLUDED.show_title,
season_number = EXCLUDED.season_number,
episode_number = EXCLUDED.episode_number,
production_year = EXCLUDED.production_year,
started_at = EXCLUDED.started_at,
stopped_at = EXCLUDED.stopped_at,
duration_ms = EXCLUDED.duration_ms,
progress_ms = EXCLUDED.progress_ms,
total_duration_ms = EXCLUDED.total_duration_ms,
watched = EXCLUDED.watched,
device = EXCLUDED.device,
player = EXCLUDED.player,
product = EXCLUDED.product,
platform = EXCLUDED.platform,
is_transcode = EXCLUDED.is_transcode,
video_decision = EXCLUDED.video_decision,
audio_decision = EXCLUDED.audio_decision,
source_video_codec = EXCLUDED.source_video_codec,
source_audio_codec = EXCLUDED.source_audio_codec,
source_fingerprint = EXCLUDED.source_fingerprint,
source_seen_at = EXCLUDED.source_seen_at,
updated_at = CASE
WHEN tracearr_sessions.source_fingerprint IS DISTINCT FROM EXCLUDED.source_fingerprint
THEN now() ELSE tracearr_sessions.updated_at END`,
session.ServerID, session.SessionID, session.UserID, session.Username,
session.State, session.MediaType, session.MediaTitle, session.ShowTitle,
session.SeasonNumber, session.EpisodeNumber, session.ProductionYear,
session.StartedAt, session.StoppedAt, session.DurationMs, session.ProgressMs,
session.TotalDurationMs, session.Watched, session.Device, session.Player,
session.Product, session.Platform, session.IsTranscode, session.VideoDecision,
session.AudioDecision, session.SourceVideoCodec, session.SourceAudioCodec,
session.EmbyItemID, session.EmbySeriesID, session.SourceFingerprint, seenAt)
}
results := s.pool.SendBatch(ctx, batch)
defer results.Close()
for range sessions {
if _, err := results.Exec(); err != nil {
return fmt.Errorf("store: upsert tracearr sessions: %w", err)
}
}
return nil
}
func (s *Store) DeleteTracearrSessionsNotSeenSince(
ctx context.Context,
serverID string,
cutoff time.Time,
) (int64, error) {
if serverID == "" {
tag, err := s.pool.Exec(ctx, `DELETE FROM tracearr_sessions WHERE source_seen_at < $1`, cutoff)
if err != nil {
return 0, fmt.Errorf("store: reconcile tracearr sessions: %w", err)
}
return tag.RowsAffected(), nil
}
tag, err := s.pool.Exec(ctx,
`DELETE FROM tracearr_sessions WHERE server_id = $1 AND source_seen_at < $2`,
serverID, cutoff)
if err != nil {
return 0, fmt.Errorf("store: reconcile tracearr sessions: %w", err)
}
return tag.RowsAffected(), nil
}
func (s *Store) TracearrSessionsForUser(
ctx context.Context,
tracearrUserID, username string,
) ([]TracearrSession, error) {
rows, err := s.pool.Query(ctx, `
SELECT server_id, tracearr_session_id, tracearr_user_id, username, state,
media_type, media_title, show_title, season_number, episode_number,
production_year, started_at, stopped_at, duration_ms, progress_ms,
total_duration_ms, watched, device, player, product, platform,
is_transcode, video_decision, audio_decision, source_video_codec,
source_audio_codec, emby_item_id, emby_series_id, source_fingerprint
FROM tracearr_sessions
WHERE ($1 <> '' AND tracearr_user_id = $1)
OR ($1 = '' AND lower(username) = lower($2))
ORDER BY started_at DESC NULLS LAST, tracearr_session_id DESC`,
tracearrUserID, username)
if err != nil {
return nil, fmt.Errorf("store: tracearr user sessions: %w", err)
}
defer rows.Close()
out := []TracearrSession{}
for rows.Next() {
var session TracearrSession
if err := rows.Scan(
&session.ServerID, &session.SessionID, &session.UserID, &session.Username,
&session.State, &session.MediaType, &session.MediaTitle, &session.ShowTitle,
&session.SeasonNumber, &session.EpisodeNumber, &session.ProductionYear,
&session.StartedAt, &session.StoppedAt, &session.DurationMs, &session.ProgressMs,
&session.TotalDurationMs, &session.Watched, &session.Device, &session.Player,
&session.Product, &session.Platform, &session.IsTranscode,
&session.VideoDecision, &session.AudioDecision, &session.SourceVideoCodec,
&session.SourceAudioCodec, &session.EmbyItemID, &session.EmbySeriesID,
&session.SourceFingerprint,
); err != nil {
return nil, err
}
out = append(out, session)
}
return out, rows.Err()
}
func (s *Store) UpdateTracearrSessionMapping(
ctx context.Context,
key TracearrSessionKey,
itemID, seriesID string,
) error {
_, err := s.pool.Exec(ctx, `
UPDATE tracearr_sessions
SET emby_item_id = $3, emby_series_id = $4
WHERE server_id = $1 AND tracearr_session_id = $2
AND (emby_item_id, emby_series_id) IS DISTINCT FROM ($3, $4)`,
key.ServerID, key.SessionID, itemID, seriesID)
if err != nil {
return fmt.Errorf("store: map tracearr session: %w", err)
}
return nil
}
func (s *Store) ForYouTracearrIdentity(ctx context.Context, userID string) (string, string, error) {
var tracearrUserID, username string
err := s.pool.QueryRow(ctx, `
SELECT tracearr_user_id, tracearr_username
FROM recommendation_user_profiles WHERE emby_user_id = $1`, userID).
Scan(&tracearrUserID, &username)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", nil
}
if err != nil {
return "", "", fmt.Errorf("store: For You Tracearr identity: %w", err)
}
return tracearrUserID, username, nil
}
func (s *Store) TracearrImportState(ctx context.Context) (TracearrImportState, error) {
var raw []byte
err := s.pool.QueryRow(ctx,
`SELECT value FROM app_settings WHERE key = $1`, tracearrImportStateKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return TracearrImportState{}, nil
}
if err != nil {
return TracearrImportState{}, fmt.Errorf("store: read tracearr import state: %w", err)
}
var state TracearrImportState
if err := json.Unmarshal(raw, &state); err != nil {
return state, fmt.Errorf("store: decode tracearr import state: %w", err)
}
return state, nil
}
func (s *Store) SetTracearrImportState(ctx context.Context, state TracearrImportState) error {
raw, err := json.Marshal(state)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
tracearrImportStateKey, string(raw))
if err != nil {
return fmt.Errorf("store: write tracearr import state: %w", err)
}
return nil
}
func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error) {
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (emby_user_id)
token_hash, emby_user_id, emby_token, username, server_id, device_id,
device_name, client_version, client_protocol, last_seen_at
FROM sessions
ORDER BY emby_user_id, last_seen_at DESC`)
if err != nil {
return nil, fmt.Errorf("store: active recommendation users: %w", err)
}
defer rows.Close()
out := []Session{}
for rows.Next() {
var session Session
if err := rows.Scan(
&session.TokenHash, &session.EmbyUserID, &session.EmbyToken, &session.Username,
&session.ServerID, &session.DeviceID, &session.DeviceName, &session.ClientVersion,
&session.ClientProtocol, &session.LastSeenAt,
); err != nil {
return nil, err
}
out = append(out, session)
}
return out, rows.Err()
}
func (s *Store) MarkForYouDirty(ctx context.Context, userID, username string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO recommendation_user_profiles (emby_user_id, tracearr_username, dirty_since)
VALUES ($1, $2, now())
ON CONFLICT (emby_user_id) DO UPDATE SET
tracearr_username = CASE WHEN $2 <> '' THEN $2 ELSE recommendation_user_profiles.tracearr_username END,
dirty_since = coalesce(recommendation_user_profiles.dirty_since, now())`,
userID, username)
if err != nil {
return fmt.Errorf("store: mark For You dirty: %w", err)
}
return nil
}
func (s *Store) MarkAllForYouProfilesDirty(ctx context.Context) error {
_, err := s.pool.Exec(ctx, `
UPDATE recommendation_user_profiles
SET dirty_since = coalesce(dirty_since, now())`)
if err != nil {
return fmt.Errorf("store: mark all For You profiles dirty: %w", err)
}
return nil
}
func (s *Store) MatchRecommendationUser(
ctx context.Context,
embyUserID, embyUsername, tracearrUserID, tracearrUsername string,
) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO recommendation_user_profiles (
emby_user_id, tracearr_user_id, tracearr_username, dirty_since
) VALUES ($1, $2, CASE WHEN $3 <> '' THEN $3 ELSE $4 END, now())
ON CONFLICT (emby_user_id) DO UPDATE SET
tracearr_user_id = EXCLUDED.tracearr_user_id,
tracearr_username = CASE
WHEN EXCLUDED.tracearr_username <> '' THEN EXCLUDED.tracearr_username
ELSE $4
END,
dirty_since = CASE
WHEN recommendation_user_profiles.tracearr_user_id IS DISTINCT FROM EXCLUDED.tracearr_user_id
OR recommendation_user_profiles.tracearr_username IS DISTINCT FROM
CASE WHEN EXCLUDED.tracearr_username <> '' THEN EXCLUDED.tracearr_username ELSE $4 END
THEN coalesce(recommendation_user_profiles.dirty_since, now())
ELSE recommendation_user_profiles.dirty_since
END`,
embyUserID, tracearrUserID, tracearrUsername, embyUsername)
if err != nil {
return fmt.Errorf("store: match recommendation user: %w", err)
}
return nil
}
func (s *Store) ForYouProfileTimes(
ctx context.Context,
userID string,
) (builtAt, poolBuiltAt, dirtySince *time.Time, err error) {
err = s.pool.QueryRow(ctx, `
SELECT built_at, pool_built_at, dirty_since
FROM recommendation_user_profiles WHERE emby_user_id = $1`, userID).
Scan(&builtAt, &poolBuiltAt, &dirtySince)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, nil, nil
}
if err != nil {
return nil, nil, nil, fmt.Errorf("store: For You freshness: %w", err)
}
return builtAt, poolBuiltAt, dirtySince, nil
}
func (s *Store) SetForYouError(ctx context.Context, userID string, buildErr error) error {
message := ""
if buildErr != nil {
message = buildErr.Error()
}
_, err := s.pool.Exec(ctx, `
INSERT INTO recommendation_user_profiles (emby_user_id, last_error, dirty_since)
VALUES ($1, $2, now())
ON CONFLICT (emby_user_id) DO UPDATE SET last_error = $2,
dirty_since = coalesce(recommendation_user_profiles.dirty_since, now())`,
userID, message)
return err
}
func (s *Store) ReplaceForYouPool(
ctx context.Context,
profile RecommendationProfile,
candidates []ForYouCandidate,
) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("store: begin For You rebuild: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
_, err = tx.Exec(ctx, `
INSERT INTO recommendation_user_profiles (
emby_user_id, tracearr_user_id, tracearr_username, source_session_count,
mean_completion_ratio, typical_session_minutes, genre_affinity,
title_affinity, studio_affinity, codec_outcomes, signals_through,
built_at, pool_built_at, dirty_since, last_error
) VALUES (
$1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb,$10::jsonb,$11,$12,$12,NULL,''
)
ON CONFLICT (emby_user_id) DO UPDATE SET
tracearr_user_id = EXCLUDED.tracearr_user_id,
tracearr_username = EXCLUDED.tracearr_username,
source_session_count = EXCLUDED.source_session_count,
mean_completion_ratio = EXCLUDED.mean_completion_ratio,
typical_session_minutes = EXCLUDED.typical_session_minutes,
genre_affinity = EXCLUDED.genre_affinity,
title_affinity = EXCLUDED.title_affinity,
studio_affinity = EXCLUDED.studio_affinity,
codec_outcomes = EXCLUDED.codec_outcomes,
signals_through = EXCLUDED.signals_through,
built_at = EXCLUDED.built_at,
pool_built_at = EXCLUDED.pool_built_at,
dirty_since = NULL,
last_error = ''`,
profile.EmbyUserID, profile.TracearrUserID, profile.TracearrUsername,
profile.SourceSessionCount, profile.MeanCompletionRatio,
profile.TypicalSessionMinutes, string(profile.GenreAffinity),
string(profile.TitleAffinity), string(profile.StudioAffinity),
string(profile.CodecOutcomes), profile.SignalsThrough, profile.BuiltAt)
if err != nil {
return fmt.Errorf("store: write For You profile: %w", err)
}
if _, err := tx.Exec(ctx,
`DELETE FROM for_you_candidates WHERE emby_user_id = $1`, profile.EmbyUserID); err != nil {
return fmt.Errorf("store: clear For You pool: %w", err)
}
batch := &pgx.Batch{}
for _, candidate := range candidates {
batch.Queue(`
INSERT INTO for_you_candidates (
emby_user_id, item_id, base_rank, base_score, runtime_minutes,
affinity_score, compatibility_score, compatibility_label, reason_kind,
reason_genre, reason_source_session_id, reason_source_item_id,
reason_source_title, recommendation_reason, built_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
profile.EmbyUserID, candidate.ItemID, candidate.BaseRank, candidate.BaseScore,
candidate.RuntimeMinutes, candidate.AffinityScore, candidate.CompatibilityScore,
candidate.CompatibilityLabel, candidate.ReasonKind, candidate.ReasonGenre,
candidate.ReasonSourceSessionID, candidate.ReasonSourceItemID,
candidate.ReasonSourceTitle, candidate.RecommendationReason, profile.BuiltAt)
}
results := tx.SendBatch(ctx, batch)
for range candidates {
if _, err := results.Exec(); err != nil {
_ = results.Close()
return fmt.Errorf("store: insert For You candidates: %w", err)
}
}
if err := results.Close(); err != nil {
return fmt.Errorf("store: close For You candidate batch: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("store: commit For You rebuild: %w", err)
}
return nil
}
func (s *Store) PreparedForYou(
ctx context.Context,
userID string,
minutes, limit int,
) ([]PreparedForYouItem, *time.Time, error) {
rows, err := s.pool.Query(ctx, `
SELECT fc.item_id, fc.base_rank, li.payload, fc.runtime_minutes,
fc.compatibility_score, fc.compatibility_label,
fc.recommendation_reason, fc.reason_kind, fc.reason_genre,
fc.reason_source_item_id, fc.reason_source_title, p.pool_built_at
FROM for_you_candidates fc
JOIN library_items li ON li.id = fc.item_id
JOIN recommendation_user_profiles p ON p.emby_user_id = fc.emby_user_id
WHERE fc.emby_user_id = $1
AND ($2 = 0 OR (fc.runtime_minutes > 0 AND fc.runtime_minutes <= $2))
ORDER BY fc.base_rank
LIMIT $3`,
userID, minutes, limit)
if err != nil {
return nil, nil, fmt.Errorf("store: prepared For You: %w", err)
}
defer rows.Close()
out := []PreparedForYouItem{}
var builtAt *time.Time
for rows.Next() {
var payload []byte
var item PreparedForYouItem
var rowBuiltAt *time.Time
if err := rows.Scan(
&item.ItemID, &item.BaseRank, &payload, &item.RuntimeMinutes,
&item.CompatibilityScore, &item.CompatibilityLabel,
&item.RecommendationReason, &item.ReasonKind, &item.ReasonGenre,
&item.ReasonSourceItemID, &item.ReasonSourceTitle, &rowBuiltAt,
); err != nil {
return nil, nil, err
}
item.Payload = json.RawMessage(payload)
out = append(out, item)
if builtAt == nil {
builtAt = rowBuiltAt
}
}
return out, builtAt, rows.Err()
}
func (s *Store) ForYouStats(ctx context.Context) (ForYouStats, error) {
var stats ForYouStats
if err := s.pool.QueryRow(ctx, `
SELECT
(SELECT count(*) FROM tracearr_sessions),
(SELECT count(*) FROM recommendation_user_profiles),
(SELECT count(*) FROM for_you_candidates)`).
Scan(&stats.TracearrSessions, &stats.Profiles, &stats.Candidates); err != nil {
return stats, fmt.Errorf("store: For You stats: %w", err)
}
state, err := s.TracearrImportState(ctx)
if err != nil {
return stats, err
}
stats.LastFullImport = state.LastFullAt
return stats, nil
}
+288
View File
@@ -0,0 +1,288 @@
// Package tracearr reads per-user playback history from Tracearr's public API.
//
// Tracearr is deliberately kept behind the Memby gateway: its operator API key never
// reaches a television, and a Tracearr outage can only reduce recommendation quality.
package tracearr
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"unicode"
)
type Client struct {
baseURL string
apiKey string
serverID string
http *http.Client
}
type Session struct {
ID string `json:"id"`
ServerID string `json:"serverId"`
State string `json:"state"`
MediaType string `json:"mediaType"`
MediaTitle string `json:"mediaTitle"`
ShowTitle string `json:"showTitle"`
SeasonNumber *int `json:"seasonNumber"`
EpisodeNumber *int `json:"episodeNumber"`
Year *int `json:"year"`
DurationMs FlexibleInt64 `json:"durationMs"`
ProgressMs FlexibleInt64 `json:"progressMs"`
TotalDurationMs FlexibleInt64 `json:"totalDurationMs"`
StartedAt string `json:"startedAt"`
StoppedAt string `json:"stoppedAt"`
Watched bool `json:"watched"`
Device string `json:"device"`
Player string `json:"player"`
Product string `json:"product"`
Platform string `json:"platform"`
IsTranscode bool `json:"isTranscode"`
VideoDecision string `json:"videoDecision"`
AudioDecision string `json:"audioDecision"`
SourceVideoCodec string `json:"sourceVideoCodec"`
SourceAudioCodec string `json:"sourceAudioCodec"`
User struct {
ID string `json:"id"`
Username string `json:"username"`
} `json:"user"`
}
// FlexibleInt64 accepts both JSON numbers and quoted integers. Tracearr currently
// serialises progress fields as strings while duration is numeric.
type FlexibleInt64 int64
func (v *FlexibleInt64) UnmarshalJSON(raw []byte) error {
raw = bytes.TrimSpace(raw)
if bytes.Equal(raw, []byte("null")) || len(raw) == 0 {
*v = 0
return nil
}
if raw[0] == '"' {
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return err
}
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return fmt.Errorf("tracearr integer %q: %w", value, err)
}
*v = FlexibleInt64(parsed)
return nil
}
parsed, err := strconv.ParseInt(string(raw), 10, 64)
if err != nil {
return fmt.Errorf("tracearr integer %q: %w", string(raw), err)
}
*v = FlexibleInt64(parsed)
return nil
}
type Page struct {
Data []Session `json:"data"`
Meta struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
} `json:"meta"`
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
SessionCount int `json:"sessionCount"`
}
type UserPage struct {
Data []User `json:"data"`
Meta struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
} `json:"meta"`
}
func New(baseURL, apiKey, serverID string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
apiKey: strings.TrimSpace(apiKey),
serverID: strings.TrimSpace(serverID),
http: &http.Client{Timeout: timeout},
}
}
// History returns the newest sessions belonging to username. Tracearr's public history
// endpoint currently has no user filter, so Memby pages through the bounded recent
// window and filters locally. Usernames are the stable identity shared by Emby and
// Tracearr; no fuzzy matching is used.
func (c *Client) History(ctx context.Context, username string, limit int) ([]Session, error) {
if c == nil || c.baseURL == "" || c.apiKey == "" || strings.TrimSpace(username) == "" {
return nil, nil
}
if limit <= 0 {
limit = 100
}
const pageSize = 100
const maxPages = 10
wanted := strings.TrimSpace(username)
out := make([]Session, 0, min(limit, pageSize))
for pageNumber := 1; pageNumber <= maxPages && len(out) < limit; pageNumber++ {
historyPage, err := c.Page(ctx, pageNumber, pageSize)
if err != nil {
return nil, err
}
for _, session := range historyPage.Data {
if strings.EqualFold(strings.TrimSpace(session.User.Username), wanted) {
out = append(out, session)
if len(out) == limit {
break
}
}
}
if pageNumber*pageSize >= historyPage.Meta.Total || len(historyPage.Data) == 0 {
break
}
}
return out, nil
}
// Page reads one public-history page. Imports use this directly so Tracearr pagination
// happens in the background rather than while a television waits.
func (c *Client) Page(ctx context.Context, pageNumber, pageSize int) (Page, error) {
endpoint, err := c.publicEndpoint("/api/v1/public/history", pageNumber, pageSize)
if err != nil {
return Page{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return Page{}, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return Page{}, fmt.Errorf("tracearr history: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return Page{}, fmt.Errorf("tracearr history: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var result Page
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&result); err != nil {
return Page{}, fmt.Errorf("tracearr history: decode: %w", err)
}
return result, nil
}
// Users lists the Tracearr identities known to the configured media server. Household
// preparation uses exact username matches against Emby; no fuzzy identity guesses are
// made here.
func (c *Client) Users(ctx context.Context, pageNumber, pageSize int) (UserPage, error) {
endpoint, err := c.publicEndpoint("/api/v1/public/users", pageNumber, pageSize)
if err != nil {
return UserPage{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return UserPage{}, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return UserPage{}, fmt.Errorf("tracearr users: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return UserPage{}, fmt.Errorf(
"tracearr users: status %d: %s",
resp.StatusCode, strings.TrimSpace(string(body)),
)
}
var result UserPage
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
return UserPage{}, fmt.Errorf("tracearr users: decode: %w", err)
}
return result, nil
}
func (c *Client) publicEndpoint(path string, pageNumber, pageSize int) (*url.URL, error) {
endpoint, err := url.Parse(c.baseURL + path)
if err != nil {
return nil, err
}
query := endpoint.Query()
query.Set("page", strconv.Itoa(pageNumber))
query.Set("pageSize", strconv.Itoa(pageSize))
if c.serverID != "" {
query.Set("serverId", c.serverID)
}
endpoint.RawQuery = query.Encode()
return endpoint, nil
}
func (c *Client) ConfiguredServerID() string {
if c == nil {
return ""
}
return c.serverID
}
// Completion is the useful fraction of a session. Tracearr's watched flag wins; for an
// interrupted play, progress is preferred and aggregate watch time is the fallback.
func (s Session) Completion() float64 {
if s.Watched {
return 1
}
if s.TotalDurationMs <= 0 {
return 0
}
progress := s.ProgressMs
if s.DurationMs > progress {
progress = s.DurationMs
}
value := float64(progress) / float64(s.TotalDurationMs)
if value < 0 {
return 0
}
if value > 1 {
return 1
}
return value
}
func (s Session) TitleKey() string {
if strings.EqualFold(s.MediaType, "episode") && strings.TrimSpace(s.ShowTitle) != "" {
return normalizeTitle(s.ShowTitle)
}
return normalizeTitle(s.MediaTitle)
}
func (s Session) IsTelevisionSession() bool {
value := strings.ToLower(strings.Join([]string{s.Device, s.Player, s.Product, s.Platform}, " "))
return strings.Contains(value, "tv") ||
strings.Contains(value, "android") ||
strings.Contains(value, "memby")
}
func normalizeTitle(value string) string {
var b strings.Builder
for _, r := range strings.ToLower(value) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
+93
View File
@@ -0,0 +1,93 @@
package tracearr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestFlexiblePlaybackNumbersAcceptTracearrStrings(t *testing.T) {
var session Session
if err := json.Unmarshal([]byte(`{
"durationMs":1234,
"progressMs":"900",
"totalDurationMs":"1800"
}`), &session); err != nil {
t.Fatal(err)
}
if session.DurationMs != 1234 || session.ProgressMs != 900 || session.TotalDurationMs != 1800 {
t.Fatalf("unexpected playback values: %+v", session)
}
}
func TestHistoryFiltersExactUserAndSendsBearerToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer trr_pub_secret" {
t.Fatalf("authorization = %q", got)
}
if got := r.URL.Query().Get("serverId"); got != "server-1" {
t.Fatalf("serverId = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"data": [
{"mediaTitle":"Arrival","mediaType":"movie","watched":true,"user":{"id":"1","username":"Matt"}},
{"mediaTitle":"Alien","mediaType":"movie","watched":true,"user":{"id":"2","username":"Other"}}
],
"meta":{"total":2,"page":1,"pageSize":100}
}`))
}))
defer server.Close()
client := New(server.URL, "trr_pub_secret", "server-1", time.Second)
history, err := client.History(context.Background(), "matt", 20)
if err != nil {
t.Fatal(err)
}
if len(history) != 1 || history[0].MediaTitle != "Arrival" {
t.Fatalf("history = %+v", history)
}
}
func TestCompletionAndTelevisionDetection(t *testing.T) {
session := Session{ProgressMs: 45, TotalDurationMs: 100, Platform: "Android TV"}
if got := session.Completion(); got != .45 {
t.Fatalf("completion = %v", got)
}
if !session.IsTelevisionSession() {
t.Fatal("expected Android TV session")
}
session.Watched = true
if got := session.Completion(); got != 1 {
t.Fatalf("watched completion = %v", got)
}
}
func TestUsersUsesPublicUsersEndpointAndServerScope(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/public/users" {
t.Fatalf("path = %q", r.URL.Path)
}
if got := r.URL.Query().Get("serverId"); got != "server-1" {
t.Fatalf("serverId = %q", got)
}
_, _ = w.Write([]byte(`{
"data":[{"id":"trace-user","username":"Matt","sessionCount":385}],
"meta":{"total":1,"page":1,"pageSize":100}
}`))
}))
defer server.Close()
page, err := New(server.URL, "secret", "server-1", time.Second).
Users(context.Background(), 1, 100)
if err != nil {
t.Fatal(err)
}
if len(page.Data) != 1 || page.Data[0].Username != "Matt" ||
page.Data[0].SessionCount != 385 {
t.Fatalf("users = %+v", page.Data)
}
}